diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/apphost.mts b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/apphost.mts
new file mode 100644
index 000000000..51ebef03e
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/apphost.mts
@@ -0,0 +1,52 @@
+import { createBuilder, CrdWaitBehavior } from './.aspire/modules/aspire.mjs';
+import { fileURLToPath } from 'node:url';
+
+const builder = await createBuilder();
+const cluster = await builder.addKindCluster('ts-kind');
+
+let manifestConfigured = false;
+const manifest = await cluster.addManifestFromContent('ts-manifest', `
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: ts-crd-wait
+`);
+await manifest.withCrdWait(async options => {
+ await options.timeout.set(120_000);
+ await options.failureBehavior.set(CrdWaitBehavior.BestEffort);
+ if (await options.timeout.get() !== 120_000 ||
+ await options.failureBehavior.get() !== CrdWaitBehavior.BestEffort) {
+ throw new Error('Manifest CRD-wait options did not round-trip through the generated SDK.');
+ }
+ manifestConfigured = true;
+});
+
+let chartConfigured = false;
+const chart = await cluster.addHelmChart('ts-chart', fileURLToPath(new URL('./chart', import.meta.url)));
+await chart.withCrdWait(async options => {
+ await options.timeout.set(180_000);
+ await options.failureBehavior.set(CrdWaitBehavior.BestEffort);
+ if (await options.timeout.get() !== 180_000 ||
+ await options.failureBehavior.get() !== CrdWaitBehavior.BestEffort) {
+ throw new Error('Helm CRD-wait options did not round-trip through the generated SDK.');
+ }
+ chartConfigured = true;
+});
+
+if (!manifestConfigured || !chartConfigured) {
+ throw new Error('CRD-wait callbacks were not invoked for both Kind builders.');
+}
+
+if (await manifest.crdWaitTimeout.get() !== 120_000 ||
+ await manifest.crdWaitBehavior.get() !== CrdWaitBehavior.BestEffort) {
+ throw new Error('Manifest CRD-wait policy did not retain the configured options.');
+}
+
+await manifest.withCrdWaitTimeout(90_000);
+await manifest.withCrdWaitBehavior(CrdWaitBehavior.Fail);
+if (await manifest.crdWaitTimeout.get() !== 90_000 ||
+ await manifest.crdWaitBehavior.get() !== CrdWaitBehavior.Fail) {
+ throw new Error('Legacy manifest CRD-wait methods did not update the configured options.');
+}
+
+await builder.build().run();
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/aspire.config.json b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/aspire.config.json
new file mode 100644
index 000000000..ea4ad82d5
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/aspire.config.json
@@ -0,0 +1,12 @@
+{
+ "appHost": {
+ "path": "apphost.mts",
+ "language": "typescript/nodejs"
+ },
+ "sdk": {
+ "version": "13.5.0"
+ },
+ "packages": {
+ "CommunityToolkit.Aspire.Hosting.Kind": "../../../src/CommunityToolkit.Aspire.Hosting.Kind/CommunityToolkit.Aspire.Hosting.Kind.csproj"
+ }
+}
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/Chart.yaml b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/Chart.yaml
new file mode 100644
index 000000000..e7b71d767
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/Chart.yaml
@@ -0,0 +1,3 @@
+apiVersion: v2
+name: crd-wait-test
+version: 0.1.0
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/templates/configmap.yaml b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/templates/configmap.yaml
new file mode 100644
index 000000000..08ec77888
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/chart/templates/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: crd-wait-test
+data:
+ test: kind
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package-lock.json b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package-lock.json
new file mode 100644
index 000000000..27bb5be4c
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package-lock.json
@@ -0,0 +1,578 @@
+{
+ "name": "kind-typescript-apphost",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "kind-typescript-apphost",
+ "version": "1.0.0",
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.3.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
+ "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
+ "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
+ "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
+ "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
+ "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
+ "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
+ "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
+ "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
+ "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
+ "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
+ "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
+ "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
+ "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
+ "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
+ "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
+ "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
+ "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
+ "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
+ "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
+ "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
+ "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
+ "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
+ "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.2",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
+ "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.2",
+ "@esbuild/android-arm": "0.28.2",
+ "@esbuild/android-arm64": "0.28.2",
+ "@esbuild/android-x64": "0.28.2",
+ "@esbuild/darwin-arm64": "0.28.2",
+ "@esbuild/darwin-x64": "0.28.2",
+ "@esbuild/freebsd-arm64": "0.28.2",
+ "@esbuild/freebsd-x64": "0.28.2",
+ "@esbuild/linux-arm": "0.28.2",
+ "@esbuild/linux-arm64": "0.28.2",
+ "@esbuild/linux-ia32": "0.28.2",
+ "@esbuild/linux-loong64": "0.28.2",
+ "@esbuild/linux-mips64el": "0.28.2",
+ "@esbuild/linux-ppc64": "0.28.2",
+ "@esbuild/linux-riscv64": "0.28.2",
+ "@esbuild/linux-s390x": "0.28.2",
+ "@esbuild/linux-x64": "0.28.2",
+ "@esbuild/netbsd-arm64": "0.28.2",
+ "@esbuild/netbsd-x64": "0.28.2",
+ "@esbuild/openbsd-arm64": "0.28.2",
+ "@esbuild/openbsd-x64": "0.28.2",
+ "@esbuild/openharmony-arm64": "0.28.2",
+ "@esbuild/sunos-x64": "0.28.2",
+ "@esbuild/win32-arm64": "0.28.2",
+ "@esbuild/win32-ia32": "0.28.2",
+ "@esbuild/win32-x64": "0.28.2"
+ }
+ },
+ "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/tsx": {
+ "version": "4.23.15",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
+ "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
+ "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/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/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package.json b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package.json
new file mode 100644
index 000000000..ced391ae0
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "kind-typescript-apphost",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "build": "tsc --noEmit"
+ },
+ "dependencies": {
+ "vscode-jsonrpc": "^8.2.0"
+ },
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "tsx": "^4.19.0",
+ "typescript": "^5.3.0"
+ }
+}
diff --git a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/tsconfig.json b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/tsconfig.json
new file mode 100644
index 000000000..19805ab48
--- /dev/null
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/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/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/AppHost.cs b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/AppHost.cs
index a1b201e80..13f957471 100644
--- a/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/AppHost.cs
+++ b/examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost/AppHost.cs
@@ -22,7 +22,6 @@
.WithHelmValue("master.service.type", "NodePort")
.WithHelmValue("master.service.nodePorts.redis", "30379")
.WithHelmStringValue("auth.password", "000123")
- .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(5))
.WithNamespace("cache");
// Apply raw Kubernetes YAML from the same host directory mounted into each Kind node.
@@ -41,7 +40,7 @@
// Demonstrate best-effort CRD waiting when a local demo should continue after a CRD wait timeout.
cluster.AddManifest("best-effort-crds", Path.Combine(manifestMountSource, "extra-config.yaml"))
- .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort);
+ .WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort);
cluster.AddManifestFromContent("demo-ns", """
apiVersion: v1
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
index b3c1a54e1..bebb93dea 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitBehavior.cs
@@ -8,17 +8,17 @@
namespace Aspire.Hosting;
///
-/// Specifies how Kind manifest resources handle CRD Established-condition wait failures.
+/// Specifies how Kind manifest and Helm chart resources handle CRD Established-condition wait failures.
///
public enum CrdWaitBehavior
{
///
- /// Fail the manifest resource when waiting for applied CRDs fails or times out.
+ /// Fail the deployed resource when waiting for discovered CRDs fails or times out.
///
Fail,
///
- /// Log a warning and continue when waiting for applied CRDs fails or times out.
+ /// Log a warning and continue with unverified CRD readiness when waiting fails or times out.
///
BestEffort,
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitOptions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitOptions.cs
new file mode 100644
index 000000000..623fad1b7
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/CrdWaitOptions.cs
@@ -0,0 +1,32 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Aspire.Hosting;
+
+///
+/// Options for waiting for CustomResourceDefinitions created by a Kind deployment.
+///
+[AspireExport(ExposeProperties = true)]
+public sealed class CrdWaitOptions
+{
+ private TimeSpan _timeout = CommunityToolkit.Aspire.Hosting.Kind.KubectlTimeouts.DefaultCrdWaitTimeout;
+
+ ///
+ /// Gets or sets the maximum time to wait for discovered CRDs to reach the Established condition.
+ ///
+ ///
+ /// Defaults to five minutes. The configured value must be greater than zero and no more than one hour;
+ /// fractional seconds are rounded up.
+ ///
+ public TimeSpan Timeout
+ {
+ get => _timeout;
+ set => _timeout = CommunityToolkit.Aspire.Hosting.Kind.KubectlTimeouts.Normalize(value, nameof(Timeout));
+ }
+
+ ///
+ /// Gets or sets how CRD wait failures are handled.
+ ///
+ /// Defaults to .
+ public CrdWaitBehavior FailureBehavior { get; set; } = CrdWaitBehavior.Fail;
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
index df7731003..90e182bc5 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/HelmManager.cs
@@ -3,144 +3,46 @@
using Aspire.Hosting.ApplicationModel;
using Microsoft.Extensions.Logging;
-using Polly;
-using Polly.Retry;
namespace CommunityToolkit.Aspire.Hosting.Kind;
///
/// Manages Helm chart deployments to a Kind cluster by orchestrating Helm CLI calls.
///
-internal sealed class HelmManager(
- IProcessRunner processRunner,
- Func? delayAsync = null,
- KubectlManager? kubectlManager = null)
+internal sealed class HelmManager(IProcessRunner processRunner)
{
- private readonly Func _delayAsync = delayAsync ?? Task.Delay;
- private readonly KubectlManager _kubectlManager = kubectlManager ?? new KubectlManager(processRunner, delayAsync);
-
///
/// Installs or upgrades the Helm release.
/// Callers should pass the Helm resource's scoped logger from .
///
- public async Task InstallAsync(KindHelmChartResource resource, ILogger resourceLogger, CancellationToken cancellationToken)
+ public async Task InstallAsync(
+ KindHelmChartResource resource,
+ ILogger resourceLogger,
+ CancellationToken cancellationToken)
{
var args = CreateInstallArguments(resource);
- var maxAttempts = resource.CrdWaitRetryMaxAttempts;
- if (maxAttempts <= 1)
- {
- resourceLogger.LogInformation(
- "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt 1/1)...",
- resource.ChartRef,
- resource.ReleaseName,
- resource.Parent.Name);
-
- var result = await processRunner.RunAsync(
- resourceLogger,
- "helm",
- args,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- if (result.ExitCode != 0)
- {
- throw new InvalidOperationException(
- $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(result)}");
- }
-
- resourceLogger.LogInformation(
- "Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
- return;
- }
+ var kubectlManager = new KubectlManager(processRunner);
+ var knownCrds = await kubectlManager.GetCustomResourceDefinitionsAsync(
+ resource.Parent.KubeconfigPath, resourceLogger, cancellationToken).ConfigureAwait(false);
- IReadOnlySet knownCrds = maxAttempts > 1
- ? await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, cancellationToken).ConfigureAwait(false)
- : new HashSet(StringComparer.OrdinalIgnoreCase);
- var attempt = 0;
- var pipeline = new ResiliencePipelineBuilder()
- .AddRetry(new RetryStrategyOptions
- {
- MaxRetryAttempts = Math.Max(0, maxAttempts - 1),
- Delay = TimeSpan.Zero,
- UseJitter = false,
- ShouldHandle = new PredicateBuilder()
- .HandleResult(static result => result.ShouldRetry),
- OnRetry = async arguments =>
- {
- var retryResult = arguments.Outcome.Result!;
- if (retryResult.NewCrds.Length > 0)
- {
- resourceLogger.LogWarning(
- "Helm release '{ReleaseName}' failed and discovered {CrdCount} new CRD(s). Waiting for them to become Established before retrying.",
- resource.ReleaseName,
- retryResult.NewCrds.Length);
-
- await _kubectlManager.WaitForCrdsAsync(
- retryResult.NewCrds,
- resource.Parent.KubeconfigPath,
- resource.CrdWaitRetryTimeout,
- resourceLogger,
- arguments.Context.CancellationToken).ConfigureAwait(false);
- }
- else
- {
- resourceLogger.LogWarning(
- "Helm release '{ReleaseName}' failed. Retrying because {MethodName} is enabled.",
- resource.ReleaseName,
- "WithCrdWaitRetry");
- }
-
- knownCrds = retryResult.DiscoveredCrds;
- var backoff = ComputeRetryBackoff(resource.CrdWaitRetryBackoff, arguments.AttemptNumber + 1);
- resourceLogger.LogInformation(
- "Retrying Helm release '{ReleaseName}' in {DelaySeconds:n1}s.",
- resource.ReleaseName,
- backoff.TotalSeconds);
- await _delayAsync(backoff, arguments.Context.CancellationToken).ConfigureAwait(false);
- }
- })
- .Build();
-
- var finalResult = await pipeline.ExecuteAsync(async token =>
- {
- attempt++;
- resourceLogger.LogInformation(
- "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}' (attempt {Attempt}/{MaxAttempts})...",
- resource.ChartRef,
- resource.ReleaseName,
- resource.Parent.Name,
- attempt,
- maxAttempts);
-
- var result = await processRunner.RunAsync(
- resourceLogger,
- "helm",
- args,
- cancellationToken: token).ConfigureAwait(false);
-
- if (result.ExitCode == 0)
- {
- return HelmInstallAttemptResult.Success(result);
- }
-
- if (attempt >= maxAttempts)
- {
- return HelmInstallAttemptResult.Fail(result);
- }
-
- var discoveredCrds = await TryGetCustomResourceDefinitionsAsync(resource, resourceLogger, token).ConfigureAwait(false);
- var newCrds = discoveredCrds
- .Except(knownCrds, StringComparer.OrdinalIgnoreCase)
- .ToArray();
-
- return HelmInstallAttemptResult.Retry(result, discoveredCrds, newCrds);
- }, cancellationToken).ConfigureAwait(false);
-
- if (finalResult.Result.ExitCode != 0)
+ resourceLogger.LogInformation(
+ "Installing Helm chart '{ChartRef}' as release '{ReleaseName}' in cluster '{ClusterName}'...",
+ resource.ChartRef, resource.ReleaseName, resource.Parent.Name);
+
+ var result = await processRunner.RunAsync(
+ resourceLogger, "helm", args, cancellationToken: cancellationToken).ConfigureAwait(false);
+
+ if (result.ExitCode != 0)
{
throw new InvalidOperationException(
- $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(finalResult.Result)}");
+ $"Failed to install Helm chart '{resource.ChartRef}' as release '{resource.ReleaseName}': {FormatFailureOutput(result)}");
}
+ var currentCrds = await kubectlManager.GetCustomResourceDefinitionsAsync(
+ resource.Parent.KubeconfigPath, resourceLogger, cancellationToken).ConfigureAwait(false);
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ [.. currentCrds.Except(knownCrds, StringComparer.OrdinalIgnoreCase)];
+
resourceLogger.LogInformation(
"Helm release '{ReleaseName}' installed successfully.", resource.ReleaseName);
}
@@ -192,57 +94,8 @@ internal static IReadOnlyList CreateInstallArguments(KindHelmChartResour
return arguments;
}
- internal static TimeSpan ComputeRetryBackoff(TimeSpan initialBackoff, int failureCount)
- {
- ArgumentOutOfRangeException.ThrowIfLessThan(failureCount, 1);
-
- if (failureCount >= 64)
- {
- return TimeSpan.MaxValue;
- }
-
- var multiplier = 1L << (failureCount - 1);
- return initialBackoff.Ticks > TimeSpan.MaxValue.Ticks / multiplier
- ? TimeSpan.MaxValue
- : TimeSpan.FromTicks(initialBackoff.Ticks * multiplier);
- }
-
private static string FormatFailureOutput(ProcessResult result)
{
return string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
}
-
- private async Task> TryGetCustomResourceDefinitionsAsync(
- KindHelmChartResource resource,
- ILogger resourceLogger,
- CancellationToken cancellationToken)
- {
- try
- {
- return await _kubectlManager.GetCustomResourceDefinitionsAsync(
- resource.Parent.KubeconfigPath,
- resourceLogger,
- cancellationToken).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- resourceLogger.LogDebug(
- ex,
- "Unable to snapshot CRDs for Helm release '{ReleaseName}'.",
- resource.ReleaseName);
- return new HashSet(StringComparer.OrdinalIgnoreCase);
- }
- }
-
- private sealed record HelmInstallAttemptResult(ProcessResult Result, bool ShouldRetry, IReadOnlySet DiscoveredCrds, string[] NewCrds)
- {
- public static HelmInstallAttemptResult Success(ProcessResult result) =>
- new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []);
-
- public static HelmInstallAttemptResult Fail(ProcessResult result) =>
- new(result, ShouldRetry: false, new HashSet(StringComparer.OrdinalIgnoreCase), []);
-
- public static HelmInstallAttemptResult Retry(ProcessResult result, IReadOnlySet discoveredCrds, string[] newCrds) =>
- new(result, ShouldRetry: true, discoveredCrds, newCrds);
- }
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/IKindPostApplyCheck.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/IKindPostApplyCheck.cs
new file mode 100644
index 000000000..b1f99abbd
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/IKindPostApplyCheck.cs
@@ -0,0 +1,15 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+///
+/// Checks a successful deployment before its initializer publishes Running.
+/// The latest apply outcome is available through .
+///
+internal interface IKindPostApplyCheck
+{
+ Task CheckAsync(KindDeployedResource resource, CancellationToken cancellationToken);
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs
index 5bf2d19ac..347f86ca0 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestAnnotations.cs
@@ -21,18 +21,9 @@ internal sealed class K8sManifestApplyOptionsAnnotation : IResourceAnnotation
public TimeSpan ApplyTimeout { get; set; } = KubectlTimeouts.DefaultApplyTimeout;
}
-internal sealed class K8sManifestCrdWaitPolicy
-{
- public TimeSpan Timeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout;
-
- public CrdWaitBehavior FailureBehavior { get; set; } = CrdWaitBehavior.Fail;
-}
-
internal sealed class K8sManifestWaitPolicyAnnotation : IResourceAnnotation
{
public TimeSpan ClusterReadyTimeout { get; set; } = TimeSpan.FromSeconds(60);
-
- public K8sManifestCrdWaitPolicy Crd { get; } = new();
}
internal static class K8sManifestAnnotations
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
index 607bddd15..2ebc99f9f 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/K8sManifestResource.cs
@@ -108,19 +108,29 @@ public TimeSpan ClusterReadyTimeout
///
/// Gets or sets the maximum time to wait for applied CRDs to reach the Established condition.
///
+ ///
+ /// Assignments must be greater than zero and no more than one hour. Fractional seconds are rounded up.
+ /// Use WithCrdWait(options => options.Timeout = value) for new code.
+ ///
+ [Obsolete("Use WithCrdWait on the resource builder instead.")]
public TimeSpan CrdWaitTimeout
{
- get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.Timeout;
- set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.Timeout = value;
+ get => this.TryGetLastAnnotation(out var policy)
+ ? policy.Options.Timeout
+ : CommunityToolkit.Aspire.Hosting.Kind.KubectlTimeouts.DefaultCrdWaitTimeout;
+ set => CommunityToolkit.Aspire.Hosting.Kind.KindCrdWaitPolicies.GetOrCreate(this).Options.Timeout = value;
}
///
/// Gets or sets how CRD wait failures are handled.
///
+ [Obsolete("Use WithCrdWait on the resource builder instead.")]
public CrdWaitBehavior CrdWaitBehavior
{
- get => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetWaitPolicy(this).Crd.FailureBehavior;
- set => CommunityToolkit.Aspire.Hosting.Kind.K8sManifestAnnotations.GetOrCreateWaitPolicy(this).Crd.FailureBehavior = value;
+ get => this.TryGetLastAnnotation(out var policy)
+ ? policy.Options.FailureBehavior
+ : Aspire.Hosting.CrdWaitBehavior.Fail;
+ set => CommunityToolkit.Aspire.Hosting.Kind.KindCrdWaitPolicies.GetOrCreate(this).Options.FailureBehavior = value;
}
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdEstablishmentCheck.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdEstablishmentCheck.cs
new file mode 100644
index 000000000..b02506633
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdEstablishmentCheck.cs
@@ -0,0 +1,203 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Net;
+using System.Net.Http;
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using k8s;
+using k8s.Autorest;
+using k8s.Models;
+using Microsoft.Extensions.Logging;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal sealed class KindCrdEstablishmentCheck(
+ ResourceLoggerService loggerService,
+ Func? kubernetesFactory = null) : IKindPostApplyCheck
+{
+ private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(250);
+ private readonly Func _kubernetesFactory = kubernetesFactory
+ ?? (static kubeconfigPath => new Kubernetes(
+ KubernetesClientConfiguration.BuildConfigFromConfigFile(kubeconfigPath)));
+
+ public async Task CheckAsync(KindDeployedResource resource, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ var crdNames = resource.TryGetLastAnnotation(out var annotation)
+ ? annotation.CrdNames
+ : null;
+ if (crdNames is null)
+ {
+ throw new InvalidOperationException($"Deployment '{resource.Name}' has not completed applying.");
+ }
+
+ if (crdNames.Count == 0)
+ {
+ return;
+ }
+
+ List remainingNames = crdNames.Select(GetDefinitionName).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
+ resource.TryGetLastAnnotation(out var waitPolicy);
+ var timeout = KubectlTimeouts.Normalize(
+ waitPolicy?.Options.Timeout ?? KubectlTimeouts.DefaultCrdWaitTimeout,
+ nameof(CrdWaitOptions.Timeout));
+ var failureBehavior = waitPolicy?.Options.FailureBehavior ?? CrdWaitBehavior.Fail;
+ ILogger logger = loggerService.GetLogger(resource);
+ logger.LogInformation(
+ "Waiting for {CrdCount} custom resource definition(s) to become Established...",
+ remainingNames.Count);
+
+ IKubernetes client;
+ try
+ {
+ client = _kubernetesFactory(resource.Parent.KubeconfigPath);
+ }
+ catch (Exception exception) when (failureBehavior == CrdWaitBehavior.BestEffort
+ && !cancellationToken.IsCancellationRequested && exception is not OperationCanceledException)
+ {
+ logger.LogWarning(exception,
+ "CRD readiness is unverified for deployment '{DeploymentName}' (BestEffort).",
+ resource.Name);
+ return;
+ }
+
+ using var waitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ waitCts.CancelAfter(timeout);
+ Task<(string Name, V1CustomResourceDefinition? Definition)>[]? activeReads = null;
+
+ try
+ {
+ while (true)
+ {
+ activeReads = remainingNames.Select(async name =>
+ (Name: name, Definition: await ReadDefinitionAsync(client, name, waitCts.Token).ConfigureAwait(false)))
+ .ToArray();
+ List> pendingReads = [.. activeReads];
+ List nextRemaining = new(remainingNames.Count);
+ while (pendingReads.Count > 0)
+ {
+ var completed = await Task.WhenAny(pendingReads).WaitAsync(waitCts.Token).ConfigureAwait(false);
+ waitCts.Token.ThrowIfCancellationRequested();
+ pendingReads.Remove(completed);
+ (string name, V1CustomResourceDefinition? definition) result;
+ try
+ {
+ result = await completed.ConfigureAwait(false);
+ }
+ finally
+ {
+ waitCts.Token.ThrowIfCancellationRequested();
+ }
+ var (name, definition) = result;
+
+ if (definition?.Status?.Conditions?.Any(condition =>
+ condition.Type == "NamesAccepted" && condition.Status == "False") == true)
+ {
+ waitCts.Cancel();
+ throw new InvalidOperationException(
+ $"Kubernetes rejected the names for custom resource definition '{name}'.");
+ }
+
+ if (definition?.Status?.Conditions?.Any(condition =>
+ condition.Type == "Established" && condition.Status == "True") != true)
+ {
+ nextRemaining.Add(name);
+ }
+ }
+ activeReads = null;
+
+ if (nextRemaining.Count == 0)
+ {
+ return;
+ }
+
+ remainingNames = nextRemaining;
+ await Task.Delay(PollInterval, waitCts.Token).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ if (failureBehavior != CrdWaitBehavior.BestEffort)
+ {
+ throw new TimeoutException(
+ $"Timed out waiting for custom resource definition(s) to become Established after {timeout}.");
+ }
+
+ logger.LogWarning(
+ "Timed out waiting for custom resource definition(s) to become Established after {Timeout}.",
+ timeout);
+ logger.LogWarning(
+ "CRD readiness is unverified for deployment '{DeploymentName}' (BestEffort).",
+ resource.Name);
+ }
+ catch (HttpOperationException exception) when (failureBehavior == CrdWaitBehavior.BestEffort && !cancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning(exception,
+ "CRD readiness is unverified for deployment '{DeploymentName}' (BestEffort).",
+ resource.Name);
+ }
+ catch (HttpRequestException exception) when (failureBehavior == CrdWaitBehavior.BestEffort && !cancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning(exception,
+ "CRD readiness is unverified for deployment '{DeploymentName}' (BestEffort).",
+ resource.Name);
+ }
+ catch (InvalidOperationException exception) when (failureBehavior == CrdWaitBehavior.BestEffort && !cancellationToken.IsCancellationRequested)
+ {
+ logger.LogWarning(exception,
+ "CRD readiness is unverified for deployment '{DeploymentName}' (BestEffort).",
+ resource.Name);
+ }
+ finally
+ {
+ if (activeReads is null)
+ {
+ client.Dispose();
+ }
+ else
+ {
+ waitCts.Cancel();
+ // A read may ignore cancellation; keep its client alive until it completes.
+ _ = Task.WhenAll(activeReads).ContinueWith(task =>
+ {
+ if (task.Exception is not null)
+ {
+ logger.LogDebug(task.Exception, "Additional CRD reads failed after the post-apply check ended.");
+ }
+ client.Dispose();
+ }, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+
+ private static async Task ReadDefinitionAsync(
+ IKubernetes client,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ return await client.ApiextensionsV1.ReadCustomResourceDefinitionAsync(
+ name, cancellationToken: cancellationToken).ConfigureAwait(false);
+ }
+ catch (HttpOperationException exception) when (exception.Response?.StatusCode == HttpStatusCode.NotFound)
+ {
+ return null;
+ }
+ }
+
+ private static string GetDefinitionName(string resourceName)
+ {
+ var separator = resourceName.IndexOf('/');
+ if (separator < 0 || separator == resourceName.Length - 1)
+ {
+ throw new InvalidOperationException($"Invalid custom resource definition name '{resourceName}'.");
+ }
+
+ return resourceName[(separator + 1)..];
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdWaitPolicyAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdWaitPolicyAnnotation.cs
new file mode 100644
index 000000000..14903f090
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindCrdWaitPolicyAnnotation.cs
@@ -0,0 +1,34 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal sealed class KindCrdWaitPolicyAnnotation : IResourceAnnotation
+{
+ public KindCrdWaitPolicyAnnotation(CrdWaitOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ Options = options;
+ }
+
+ public CrdWaitOptions Options { get; set; }
+}
+
+internal static class KindCrdWaitPolicies
+{
+ public static KindCrdWaitPolicyAnnotation GetOrCreate(KindDeployedResource resource)
+ {
+ if (resource.TryGetLastAnnotation(out var annotation))
+ {
+ return annotation;
+ }
+
+ annotation = new KindCrdWaitPolicyAnnotation(new CrdWaitOptions());
+ resource.Annotations.Add(annotation);
+ return annotation;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeployedResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeployedResourceBuilderExtensions.cs
new file mode 100644
index 000000000..376012051
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeployedResourceBuilderExtensions.cs
@@ -0,0 +1,53 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+using CommunityToolkit.Aspire.Hosting.Kind;
+
+namespace Aspire.Hosting;
+
+///
+/// Extension methods for resources deployed to Kind clusters.
+///
+public static class KindDeployedResourceBuilderExtensions
+{
+ ///
+ /// Configures how the post-apply check waits for CRDs to reach the
+ /// Established condition before the resource becomes running.
+ ///
+ /// The deployed resource type.
+ /// The Kind deployment resource builder.
+ /// The callback used to configure the CRD wait options.
+ /// A reference to the .
+ ///
+ /// Repeated calls start from the current policy and preserve options that the callback does not change.
+ /// Timeout assignments are validated immediately. Changes are applied only after the callback completes.
+ ///
+ [AspireExport(RunSyncOnBackgroundThread = true)]
+ public static IResourceBuilder WithCrdWait(
+ this IResourceBuilder builder,
+ Action configure)
+ where T : KindDeployedResource
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+ ArgumentNullException.ThrowIfNull(configure);
+
+ builder.Resource.TryGetLastAnnotation(out var policy);
+ var options = new CrdWaitOptions
+ {
+ Timeout = policy?.Options.Timeout ?? KubectlTimeouts.DefaultCrdWaitTimeout,
+ FailureBehavior = policy?.Options.FailureBehavior ?? CrdWaitBehavior.Fail,
+ };
+ configure(options);
+
+ if (policy is null)
+ {
+ builder.Resource.Annotations.Add(new KindCrdWaitPolicyAnnotation(options));
+ }
+ else
+ {
+ policy.Options = options;
+ }
+ return builder;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeploymentOutcomeAnnotation.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeploymentOutcomeAnnotation.cs
new file mode 100644
index 000000000..72339f5a3
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindDeploymentOutcomeAnnotation.cs
@@ -0,0 +1,26 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal sealed class KindDeploymentOutcomeAnnotation : IResourceAnnotation
+{
+ public IReadOnlyList? CrdNames { get; set; }
+}
+
+internal static class KindDeploymentOutcomes
+{
+ public static KindDeploymentOutcomeAnnotation GetOrCreate(KindDeployedResource resource)
+ {
+ if (resource.TryGetLastAnnotation(out var annotation))
+ {
+ return annotation;
+ }
+
+ annotation = new KindDeploymentOutcomeAnnotation();
+ resource.Annotations.Add(annotation);
+ return annotation;
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
index af718bbb6..7c5d83a0d 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResource.cs
@@ -48,10 +48,4 @@ public class KindHelmChartResource(string name, string chartRef, KindClusterReso
/// Gets the inline Helm values that must be applied with --set-string key=value.
///
public Dictionary StringValues { get; } = [];
-
- internal int CrdWaitRetryMaxAttempts { get; set; } = 1;
-
- internal TimeSpan CrdWaitRetryTimeout { get; set; } = KubectlTimeouts.DefaultCrdWaitTimeout;
-
- internal TimeSpan CrdWaitRetryBackoff { get; set; } = KubectlTimeouts.DefaultCrdWaitRetryBackoff;
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
index 7ac5615e1..c55ec21d3 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindHelmChartResourceBuilderExtensions.cs
@@ -36,6 +36,7 @@ public static IResourceBuilder AddHelmChart(
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(name);
ArgumentNullException.ThrowIfNull(chartRef);
+ builder.ApplicationBuilder.Services.AddKindInfrastructure();
var resource = new KindHelmChartResource(name, chartRef, builder.Resource);
@@ -91,6 +92,7 @@ await notifications.PublishUpdateAsync(resource,
var processRunner = e.Services.GetRequiredService();
var helmManager = new HelmManager(processRunner);
await helmManager.InstallAsync(resource, logger, ct);
+ await e.Services.GetRequiredService().RunAsync(resource, ct);
await notifications.PublishUpdateAsync(resource,
state => state with
@@ -183,42 +185,6 @@ public static IResourceBuilder WithHelmStringValue(
return builder;
}
- ///
- /// Retries Helm installs that race newly-created CRDs which have not reached the
- /// Established condition yet.
- ///
- /// The Helm chart resource builder.
- /// The total number of install attempts. Must be 2 or greater.
- ///
- /// The initial delay before retrying. Later retries back off exponentially.
- /// When , Kind uses a 5 second initial backoff.
- ///
- ///
- /// The timeout used while waiting for newly discovered CRDs to become Established
- /// between retry attempts. When , Kind uses a 5 minute timeout.
- ///
- /// A reference to the .
- ///
- /// This is useful for charts that create CRDs and immediately render custom resources
- /// that depend on those CRDs. Between attempts, Kind waits for newly observed CRDs
- /// to report Established.
- ///
- [AspireExport]
- public static IResourceBuilder WithCrdWaitRetry(
- this IResourceBuilder builder,
- int maxAttempts = 3,
- TimeSpan? backoff = null,
- TimeSpan? crdWaitTimeout = null)
- {
- ArgumentNullException.ThrowIfNull(builder);
- ArgumentOutOfRangeException.ThrowIfLessThan(maxAttempts, 2);
-
- builder.Resource.CrdWaitRetryMaxAttempts = maxAttempts;
- builder.Resource.CrdWaitRetryBackoff = KubectlTimeouts.Normalize(backoff ?? KubectlTimeouts.DefaultCrdWaitRetryBackoff, nameof(backoff));
- builder.Resource.CrdWaitRetryTimeout = KubectlTimeouts.Normalize(crdWaitTimeout ?? KubectlTimeouts.DefaultCrdWaitTimeout, nameof(crdWaitTimeout));
- return builder;
- }
-
///
/// Adds a values file (maps to helm install -f path).
///
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindInfrastructureExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindInfrastructureExtensions.cs
index 2c8637043..e7f75eb10 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindInfrastructureExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindInfrastructureExtensions.cs
@@ -19,6 +19,8 @@ internal static IServiceCollection AddKindInfrastructure(this IServiceCollection
{
services.TryAddSingleton();
services.TryAddSingleton();
+ services.TryAddEnumerable(ServiceDescriptor.Singleton());
+ services.TryAddSingleton();
return services;
}
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
index 85808a0a6..ccdfc615e 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindManifestResourceBuilderExtensions.cs
@@ -27,8 +27,12 @@ public static class KindManifestResourceBuilderExtensions
/// A reference to the .
///
/// The manifest is applied after the parent Kind cluster reaches the
- /// state. Downstream resources that call
- /// WaitFor on the manifest resource only start once kubectl apply succeeds.
+ /// state. After apply succeeds, the manifest
+ /// becomes running after directly applied CRDs have been checked once for Established.
+ /// By default, a failed check prevents the manifest from running; with
+ /// , it runs with CRD readiness unverified.
+ /// Downstream resources that call WaitFor start after the check completes
+ /// according to the configured behavior.
/// Requires kubectl on PATH.
///
[AspireExport]
@@ -77,10 +81,46 @@ public static IResourceBuilder AddManifestFromContent(
return AddManifestResource(builder, resource);
}
+ ///
+ /// Sets the maximum time to wait for applied CRDs to reach the Established condition.
+ ///
+ /// The manifest resource builder.
+ /// The CRD wait timeout.
+ /// A reference to the .
+ [AspireExport]
+ [Obsolete("Use WithCrdWait instead.")]
+ public static IResourceBuilder WithCrdWaitTimeout(
+ this IResourceBuilder builder,
+ TimeSpan timeout)
+ {
+ return KindDeployedResourceBuilderExtensions.WithCrdWait(
+ builder,
+ options => options.Timeout = timeout);
+ }
+
+ ///
+ /// Sets whether CRD wait failures prevent the manifest from running.
+ ///
+ /// The manifest resource builder.
+ /// The CRD wait behavior.
+ /// A reference to the .
+ [AspireExport]
+ [Obsolete("Use WithCrdWait instead.")]
+ public static IResourceBuilder WithCrdWaitBehavior(
+ this IResourceBuilder builder,
+ CrdWaitBehavior behavior)
+ {
+ return KindDeployedResourceBuilderExtensions.WithCrdWait(
+ builder,
+ options => options.FailureBehavior = behavior);
+ }
+
private static IResourceBuilder AddManifestResource(
IResourceBuilder builder,
K8sManifestResource resource)
{
+ builder.ApplicationBuilder.Services.AddKindInfrastructure();
+
var resourceBuilder = builder.ApplicationBuilder
.AddResource(resource)
.ExcludeFromManifest()
@@ -114,6 +154,7 @@ await notifications.PublishUpdateAsync(resource,
var processRunner = e.Services.GetRequiredService();
var kubectlManager = CreateKubectlManager(processRunner, resource);
await kubectlManager.ApplyAsync(resource, logger, ct);
+ await e.Services.GetRequiredService().RunAsync(resource, ct);
var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
await notifications.PublishUpdateAsync(resource,
@@ -253,41 +294,6 @@ public static IResourceBuilder WithApplyTimeout(
K8sManifestAnnotations.GetOrCreateApplyOptions(builder.Resource).ApplyTimeout = KubectlTimeouts.Normalize(timeout, nameof(timeout));
return builder;
}
-
- ///
- /// Sets the maximum time to wait for applied CRDs to reach the Established condition.
- ///
- /// The manifest resource builder.
- /// The CRD wait timeout.
- /// A reference to the .
- [AspireExport]
- public static IResourceBuilder WithCrdWaitTimeout(
- this IResourceBuilder builder,
- TimeSpan timeout)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.Timeout = KubectlTimeouts.Normalize(timeout, nameof(timeout));
- return builder;
- }
-
- ///
- /// Sets whether CRD wait failures fail the manifest resource or are logged as best-effort warnings.
- ///
- /// The manifest resource builder.
- /// The CRD wait behavior.
- /// A reference to the .
- [AspireExport]
- public static IResourceBuilder WithCrdWaitBehavior(
- this IResourceBuilder builder,
- CrdWaitBehavior behavior)
- {
- ArgumentNullException.ThrowIfNull(builder);
-
- K8sManifestAnnotations.GetOrCreateWaitPolicy(builder.Resource).Crd.FailureBehavior = behavior;
- return builder;
- }
-
}
#pragma warning restore ASPIREATS001
\ No newline at end of file
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KindPostApplyChecks.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KindPostApplyChecks.cs
new file mode 100644
index 000000000..9d0b15196
--- /dev/null
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KindPostApplyChecks.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting.ApplicationModel;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind;
+
+internal sealed class KindPostApplyChecks(IEnumerable checks)
+{
+ public async Task RunAsync(KindDeployedResource resource, CancellationToken cancellationToken)
+ {
+ foreach (var check in checks)
+ {
+ await check.CheckAsync(resource, cancellationToken).ConfigureAwait(false);
+ }
+ }
+}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
index 797f8faca..d8a1155c8 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlManager.cs
@@ -18,17 +18,17 @@ internal sealed class KubectlManager(
IProcessRunner processRunner,
Func? delayAsync = null,
TimeSpan? clusterInfoMaxWait = null,
- TimeSpan? clusterInfoProbeTimeout = null)
+ TimeSpan? apiProbeTimeout = null)
{
private const string KubectlNotFoundMessage = "kubectl CLI not found. Install it from https://kubernetes.io/docs/tasks/tools/";
private static readonly TimeSpan ClusterInfoMaxWait = TimeSpan.FromSeconds(60);
- private static readonly TimeSpan ClusterInfoProbeTimeout = TimeSpan.FromSeconds(10);
+ private static readonly TimeSpan ApiProbeTimeout = TimeSpan.FromSeconds(10);
private static readonly TimeSpan ClusterInfoInitialDelay = TimeSpan.FromSeconds(1);
private static readonly TimeSpan ClusterInfoMaxDelay = TimeSpan.FromSeconds(10);
private readonly Func _delayAsync = delayAsync ?? Task.Delay;
private readonly TimeSpan _clusterInfoMaxWait = clusterInfoMaxWait ?? ClusterInfoMaxWait;
- private readonly TimeSpan _clusterInfoProbeTimeout = clusterInfoProbeTimeout ?? ClusterInfoProbeTimeout;
+ private readonly TimeSpan _apiProbeTimeout = apiProbeTimeout ?? ApiProbeTimeout;
internal TimeSpan ClusterInfoMaxWaitForTesting => _clusterInfoMaxWait;
@@ -36,7 +36,10 @@ internal sealed class KubectlManager(
/// Waits for the cluster API to answer, then applies the manifest via kubectl apply.
/// Callers should pass the manifest resource's scoped logger from .
///
- public async Task ApplyAsync(K8sManifestResource resource, ILogger resourceLogger, CancellationToken cancellationToken)
+ public async Task ApplyAsync(
+ K8sManifestResource resource,
+ ILogger resourceLogger,
+ CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(resource);
var applyOptions = K8sManifestAnnotations.GetApplyOptions(resource);
@@ -78,11 +81,7 @@ public async Task ApplyAsync(K8sManifestResource resource, ILogger resourceLogge
$"Failed to apply manifest '{resource.ManifestPath}' to cluster '{resource.Parent.Name}': {FormatFailureOutput(result)}");
}
- var crdNames = GetAppliedCrdNames(result.Output);
- if (crdNames.Count > 0)
- {
- await WaitForCrdsAsync(crdNames, resource, resourceLogger, cancellationToken).ConfigureAwait(false);
- }
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames = GetAppliedCrdNames(result.Output);
resourceLogger.LogInformation(
"Manifest '{ManifestPath}' applied successfully.", resource.ManifestPath);
@@ -91,68 +90,28 @@ public async Task ApplyAsync(K8sManifestResource resource, ILogger resourceLogge
///
/// Waits for applied CRDs to reach the Kubernetes Established condition.
///
- internal async Task WaitForCrdsAsync(
- IEnumerable crdNames,
- K8sManifestResource resource,
+ internal async Task> GetCustomResourceDefinitionsAsync(
+ string kubeconfigPath,
ILogger resourceLogger,
CancellationToken cancellationToken)
{
- ArgumentNullException.ThrowIfNull(crdNames);
- ArgumentNullException.ThrowIfNull(resource);
+ ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
- var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- if (crds.Length == 0)
+ using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ probeCts.CancelAfter(_apiProbeTimeout);
+ ProcessResult result;
+ try
{
- return;
+ result = await RunKubectlAsync(
+ resourceLogger,
+ CreateGetCrdsArguments(kubeconfigPath),
+ cancellationToken: probeCts.Token).ConfigureAwait(false);
}
-
- var waitPolicy = K8sManifestAnnotations.GetWaitPolicy(resource);
- var args = CreateWaitArguments(crds, resource.Parent.KubeconfigPath, waitPolicy.Crd.Timeout);
-
- resourceLogger.LogInformation(
- "Waiting for {CrdCount} custom resource definition(s) to become Established...",
- crds.Length);
-
- var result = await RunKubectlAsync(
- resourceLogger,
- args,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- if (result.ExitCode != 0)
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
- var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
- if (waitPolicy.Crd.FailureBehavior == CrdWaitBehavior.BestEffort)
- {
- resourceLogger.LogWarning(
- "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}",
- message);
- return;
- }
-
- throw new InvalidOperationException(
- $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}");
+ throw new TimeoutException(
+ $"Timed out querying custom resource definitions after {_apiProbeTimeout}.");
}
- }
-
- internal Task WaitForCrdsAsync(
- IEnumerable crdNames,
- string kubeconfigPath,
- TimeSpan timeout,
- ILogger resourceLogger,
- CancellationToken cancellationToken) =>
- WaitForCrdsCoreAsync(crdNames, kubeconfigPath, timeout, resourceLogger, cancellationToken, bestEffort: false);
-
- internal async Task> GetCustomResourceDefinitionsAsync(
- string kubeconfigPath,
- ILogger resourceLogger,
- CancellationToken cancellationToken)
- {
- ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
-
- var result = await RunKubectlAsync(
- resourceLogger,
- CreateGetCrdsArguments(kubeconfigPath),
- cancellationToken: cancellationToken).ConfigureAwait(false);
if (result.ExitCode != 0)
{
@@ -226,30 +185,7 @@ internal static IReadOnlyList CreateApplyArguments(K8sManifestResource r
arguments.Add(applyOptions.FieldManager);
}
- return arguments;
- }
-
- ///
- /// Creates the kubectl wait argument list for applied CRDs.
- ///
- internal static IReadOnlyList CreateWaitArguments(
- IEnumerable crdNames,
- string kubeconfigPath,
- TimeSpan timeout)
- {
- ArgumentNullException.ThrowIfNull(crdNames);
- ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
-
- List arguments =
- [
- "wait",
- "--for=condition=Established",
- ];
-
- arguments.AddRange(crdNames);
- arguments.Add($"--timeout={KubectlTimeouts.ToSeconds(timeout, nameof(timeout))}s");
- arguments.Add($"--kubeconfig={kubeconfigPath}");
-
+ arguments.Add("--output=name");
return arguments;
}
@@ -344,7 +280,7 @@ internal async Task WaitForClusterInfoAsync(
await pipeline.ExecuteAsync(async token =>
{
using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(token);
- probeCts.CancelAfter(_clusterInfoProbeTimeout);
+ probeCts.CancelAfter(_apiProbeTimeout);
try
{
@@ -480,52 +416,6 @@ private static IReadOnlyList GetAppliedCrdNames(string output)
return [.. crds];
}
- private async Task WaitForCrdsCoreAsync(
- IEnumerable crdNames,
- string kubeconfigPath,
- TimeSpan timeout,
- ILogger resourceLogger,
- CancellationToken cancellationToken,
- bool bestEffort)
- {
- ArgumentNullException.ThrowIfNull(crdNames);
- ArgumentException.ThrowIfNullOrWhiteSpace(kubeconfigPath);
-
- var crds = crdNames.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
- if (crds.Length == 0)
- {
- return;
- }
-
- var args = CreateWaitArguments(crds, kubeconfigPath, timeout);
-
- resourceLogger.LogInformation(
- "Waiting for {CrdCount} custom resource definition(s) to become Established...",
- crds.Length);
-
- var result = await RunKubectlAsync(
- resourceLogger,
- args,
- cancellationToken: cancellationToken).ConfigureAwait(false);
-
- if (result.ExitCode == 0)
- {
- return;
- }
-
- var message = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
- if (bestEffort)
- {
- resourceLogger.LogWarning(
- "Timed out or failed while waiting for custom resource definition(s) to become Established: {Error}",
- message);
- return;
- }
-
- throw new InvalidOperationException(
- $"Timed out or failed while waiting for custom resource definition(s) to become Established: {message}");
- }
-
private static IReadOnlySet ParseResourceNames(string output)
{
if (string.IsNullOrWhiteSpace(output))
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
index cee6dda79..02b5b20ec 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/KubectlTimeouts.cs
@@ -7,7 +7,6 @@ internal static class KubectlTimeouts
{
internal static readonly TimeSpan DefaultApplyTimeout = TimeSpan.FromMinutes(5);
internal static readonly TimeSpan DefaultCrdWaitTimeout = TimeSpan.FromMinutes(5);
- internal static readonly TimeSpan DefaultCrdWaitRetryBackoff = TimeSpan.FromSeconds(5);
internal static readonly TimeSpan MaximumTimeout = TimeSpan.FromHours(1);
internal static TimeSpan Normalize(TimeSpan timeout, string parameterName)
@@ -24,9 +23,4 @@ internal static TimeSpan Normalize(TimeSpan timeout, string parameterName)
return TimeSpan.FromSeconds(Math.Ceiling(timeout.TotalSeconds));
}
-
- internal static int ToSeconds(TimeSpan timeout, string parameterName)
- {
- return (int)Normalize(timeout, parameterName).TotalSeconds;
- }
}
diff --git a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
index 5bb79a3fb..ba678af9c 100644
--- a/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
+++ b/src/CommunityToolkit.Aspire.Hosting.Kind/README.md
@@ -6,7 +6,7 @@ An [Aspire](https://learn.microsoft.com/dotnet/aspire) hosting integration that
- **Docker or Podman** - Kind runs Kubernetes nodes as containers. Install [Docker](https://docs.docker.com/get-docker/) or [Podman](https://podman.io/docs/installation).
- **Kind CLI** - The `kind` command must be available on your `PATH`. Install from [kind.sigs.k8s.io](https://kind.sigs.k8s.io/docs/user/quick-start/#installation).
-- **kubectl CLI** - Required for `AddManifest` / `AddManifestFromContent`. Install from [kubernetes.io](https://kubernetes.io/docs/tasks/tools/).
+- **kubectl CLI** - Required for `AddHelmChart`, `AddManifest`, and `AddManifestFromContent`. Install from [kubernetes.io](https://kubernetes.io/docs/tasks/tools/).
- **Helm CLI** - Required for deploy scenarios and Helm chart resources. Install from [helm.sh](https://helm.sh/docs/intro/install/).
## Getting started
@@ -170,10 +170,13 @@ var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicha
.WithChartVersion("20.0.0")
.WithHelmValue("replica.replicaCount", "0")
.WithHelmStringValue("auth.password", "000123")
- .WithCrdWaitRetry()
.WithNamespace("cache");
```
+Before installing a chart, Kind lists the cluster's CRDs. After a successful `helm upgrade --install`, it lists them again and uses the Kubernetes client to wait **once** for all newly observed CRDs to reach `Established` (with a five-minute timeout by default) **before** marking the release `Running`. Use `.WithCrdWait(options => options.Timeout = ...)` to adjust the CRD wait deadline. Each CRD listing has a ten-second API probe deadline. If no new CRDs are observed, no CRD wait runs. A failed install or CRD query sets `FailedToStart` without publishing `Running`; CRD wait failures do the same by default. Set `options.FailureBehavior = CrdWaitBehavior.BestEffort` to instead log unverified CRD readiness and permit `Running` after a CRD wait failure, including failure to create the Kubernetes client from the kubeconfig. Kind does not retry the Helm command. The separate workload health check continues to run after startup, and a workload health failure can leave a successfully installed release `Running` but `Unhealthy`. `.WaitFor(redis)` stays blocked until the CRD check completes **and** workload health passes; if no workloads are tracked, the workload check passes after installation.
+
+The CRD comparison does not establish chart ownership: it does not recheck existing CRDs and may include CRDs created concurrently by another installer. CRDs generated later by a controller are not covered.
+
#### WithHelmStringValue
Use `WithHelmStringValue` when a value looks numeric or boolean but must remain a string in the rendered chart. Kind emits `--set-string` for these values instead of `--set`.
@@ -183,23 +186,9 @@ var redis = cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicha
.WithHelmStringValue("auth.password", "000123");
```
-#### WithCrdWaitRetry
-
-Use `WithCrdWaitRetry` as an explicit opt-in retry policy for Helm installs. Without it, Kind makes a single Helm attempt and surfaces the original failure immediately. With it, Kind retries failed installs up to the configured attempt count, waits for any newly observed CRDs to reach `Established` between attempts, and then re-runs Helm after the configured backoff.
-
-Prefer `.WaitFor(...)` ordering or packaging tightly coupled CRDs and dependents into a single chart when you can; `WithCrdWaitRetry` is a fallback for charts that still need bounded retry behavior.
-
-```csharp
-var certManager = cluster.AddHelmChart("cert-manager", "jetstack/cert-manager")
- .WithCrdWaitRetry(
- maxAttempts: 3,
- backoff: TimeSpan.FromSeconds(5),
- crdWaitTimeout: TimeSpan.FromMinutes(2));
-```
-
### Applying raw manifests to the cluster
-Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) to the cluster after it becomes healthy. This runs `kubectl apply -f --kubeconfig ` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Manifest paths must be absolute so published AppHosts do not depend on the original AppHost project directory.
+Use `AddManifest` to apply a Kubernetes manifest (file, directory, or Kustomize overlay) after the Kind cluster becomes `Running`. This runs `kubectl apply -f --kubeconfig --output=name` against the cluster kubeconfig and is the natural equivalent of `AddHelmChart` for scenarios where a chart would be overkill. Manifest paths must be absolute so published AppHosts do not depend on the original AppHost project directory.
`AddManifest` supports a single file, a directory, or a Kustomize overlay. URL fetch is not supported today — use a local file. For URL support, `curl` the file down as a build step and reference the local path.
@@ -232,7 +221,7 @@ cluster.AddManifestFromContent("demo-ns", """
""");
```
-Downstream resources can wait on the manifest resource before starting so they only see the cluster after the manifests have been applied:
+Downstream resources can wait on the manifest resource before starting. `WaitFor` waits for the manifest to be both running and healthy:
```csharp
var crds = cluster.AddManifest("crds", Path.Combine(manifestsRoot, "crds.yaml"));
@@ -244,7 +233,26 @@ var operatorContainer = builder.AddContainer("my-operator", "my-org/operator")
Manifests persist with the cluster - deleted with session clusters, retained with persistent clusters.
-When `kubectl apply` reports custom resource definitions, Kind waits up to 5 minutes for those CRDs to reach the `Established` condition before marking the manifest resource running. The default behavior is fail-fast: a CRD wait timeout fails the manifest resource. Use `.WithCrdWaitTimeout(...)` to adjust the timeout, or `.WithCrdWaitBehavior(CrdWaitBehavior.BestEffort)` to log a warning and continue.
+After `kubectl apply` succeeds, Kind queries **all directly applied CRDs** reported by `kubectl apply` concurrently through the Kubernetes client until they reach the `Established` condition, then marks the manifest `Running` and releases `WaitFor` dependents. Subsequent polls query only CRDs not yet seen `Established`; the same applies to CRDs discovered after a Helm install. Missing CRDs are retried; CRDs whose names Kubernetes rejects fail the strict check immediately. A successful apply with no directly applied CRDs skips the wait and becomes `Running`; this does not verify readiness of other object kinds or CRDs later generated by a controller.
+
+The CRD check has a five-minute deadline by default. With the default strict policy, a failed CRD wait sets `FailedToStart` without ever publishing `Running`; `WaitFor` dependents remain blocked. Apply failures also set `FailedToStart`. For both manifests and Helm charts, configure the policy with `WithCrdWait`:
+
+```csharp
+cluster.AddManifest("crds", Path.Combine(manifestsRoot, "crds.yaml"))
+ .WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromMinutes(2);
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ });
+```
+
+`BestEffort` logs an explicit **unverified** warning and permits `Running` after a failed CRD wait (including Kubernetes client creation failure), **without verifying CRD readiness**. Dependents using `WaitFor` can then start; use the default strict policy when their startup requires those CRDs. Repeated `WithCrdWait` calls preserve options that the callback does not change. CRD waits are not recurring health probes: later health checks do not repeat the five-minute wait.
+
+The [TypeScript AppHost example](../../examples/kind/CommunityToolkit.Aspire.Hosting.Kind.AppHost.TypeScript/apphost.mts) exercises the generated `withCrdWait` callback for both manifests and Helm charts, and the retained manifest `withCrdWaitTimeout` and `withCrdWaitBehavior` methods.
+
+Existing manifest-specific `WithCrdWaitTimeout` and `WithCrdWaitBehavior` overloads and the `K8sManifestResource.CrdWaitTimeout` and `CrdWaitBehavior` properties remain available but are obsolete compatibility shims. New code should use `WithCrdWait`. The obsolete `CrdWaitTimeout` property now validates when assigned, matching `CrdWaitOptions.Timeout`: zero, negative, and values over one hour throw immediately, while fractional seconds round up. Migrate direct property assignments to `WithCrdWait(options => options.Timeout = value)`.
+
+A `WaitFor` dependent remains waiting when the manifest is `FailedToStart`; it does not fail fast when CRD readiness fails. Aspire currently rejects `.WaitFor(crdManifest)` on another manifest under the **same Kind cluster**, because it implicitly waits on the dependency's parent (also the consumer's parent). This integration does not change that sibling-manifest ordering limitation; put resources that require ordering in a single multi-document manifest instead of using `WaitFor` between sibling manifests.
#### WithClusterReadyTimeout
@@ -261,8 +269,8 @@ cluster.AddManifest("platform", Path.Combine(builder.AppHostDirectory, "manifest
If a manifest declares its own `metadata.namespace` that conflicts with `--namespace`, `kubectl` rejects the apply. Prefer one of these patterns:
-- Create the namespace with a separate `AddManifest("ns", Path.Combine(builder.AppHostDirectory, "manifests", "namespace.yaml"))` and `.WaitFor()` it before applying namespaced manifests.
-- Omit `.WithNamespace()` and let each manifest declare its own namespace.
+- Put the `Namespace` document first in the same multi-document manifest file as its namespaced objects, set their `metadata.namespace`, and apply the file with one `AddManifest(...)` resource (without `.WithNamespace()`).
+- If the namespace already exists, omit `.WithNamespace()` and let each namespaced object declare its own `metadata.namespace`.
#### WithServerSideApply
@@ -360,8 +368,7 @@ builder.AddContainer("my-container", "my-image")
| `WithServerSideApply(bool forceConflicts = false)` | For `AddManifest`: use server-side apply, optionally with `--force-conflicts` |
| `WithFieldManager(string)` | For `AddManifest`: set the `kubectl apply --field-manager` identifier |
| `WithApplyTimeout(TimeSpan)` | For `AddManifest`: set the maximum time for `kubectl apply` |
-| `WithCrdWaitTimeout(TimeSpan)` | For `AddManifest`: set the CRD `Established` wait timeout |
-| `WithCrdWaitBehavior(CrdWaitBehavior)` | For `AddManifest`: choose fail-fast or best-effort CRD wait behavior |
+| `WithCrdWait(Action)` | For `AddManifest` or `AddHelmChart`: configure the CRD `Established` timeout and failure behavior |
| `WithKind()` | Configures a `KubernetesEnvironmentResource` to deploy to a local Kind cluster (scenario 2) |
## Security & scope notes
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/CommunityToolkit.Aspire.Hosting.Kind.Tests.csproj b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/CommunityToolkit.Aspire.Hosting.Kind.Tests.csproj
index 766b3afc1..e98d9663d 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/CommunityToolkit.Aspire.Hosting.Kind.Tests.csproj
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/CommunityToolkit.Aspire.Hosting.Kind.Tests.csproj
@@ -9,6 +9,7 @@
+
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeCrdClient.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeCrdClient.cs
new file mode 100644
index 000000000..4d2eacfa0
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/FakeCrdClient.cs
@@ -0,0 +1,68 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using k8s;
+using k8s.Autorest;
+using k8s.Models;
+using Moq;
+using System.Net;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
+
+internal sealed class FakeCrdClient
+{
+ private readonly Mock _operations = new();
+ private readonly Mock _client = new();
+
+ public FakeCrdClient(Func> read)
+ {
+ _operations.Setup(api => api.ReadCustomResourceDefinitionWithHttpMessagesAsync(
+ It.IsAny(), It.IsAny(),
+ It.IsAny>>(),
+ It.IsAny()))
+ .Returns(async (string name, bool? _,
+ IReadOnlyDictionary> _, CancellationToken token) =>
+ new HttpOperationResponse
+ {
+ Body = await read(name, token),
+ Response = new HttpResponseMessage(),
+ });
+ _client.Setup(client => client.ApiextensionsV1).Returns(_operations.Object);
+ }
+
+ public IKubernetes Client => _client.Object;
+
+ public int ReadCount => _operations.Invocations.Count;
+
+ public IReadOnlyList ReadNames => _operations.Invocations
+ .Select(invocation => (string)invocation.Arguments[0]).ToList();
+
+ public int DisposeCount => _client.Invocations.Count(invocation => invocation.Method.Name == nameof(IDisposable.Dispose));
+
+ public static FakeCrdClient Established(params string[] names) =>
+ new((name, _) => names.Contains(name, StringComparer.Ordinal)
+ ? Task.FromResult(Definition(name, established: true))
+ : Task.FromException(new InvalidOperationException($"Unexpected CRD: {name}")));
+
+ public static V1CustomResourceDefinition Definition(string name, bool established, bool namesAccepted = true) =>
+ new()
+ {
+ Metadata = new V1ObjectMeta { Name = name },
+ Status = new V1CustomResourceDefinitionStatus
+ {
+ Conditions =
+ [
+ new V1CustomResourceDefinitionCondition
+ {
+ Type = "NamesAccepted",
+ Status = namesAccepted ? "True" : "False",
+ },
+ new V1CustomResourceDefinitionCondition
+ {
+ Type = "Established",
+ Status = established ? "True" : "False",
+ },
+ ],
+ },
+ };
+}
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
index c94f86ced..b68bad4b7 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindHelmChartTests.cs
@@ -4,6 +4,8 @@
using Aspire.Hosting;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
+using k8s;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -158,72 +160,6 @@ public void WithHelmStringValueAndValueUseLastWriteWinsAcrossModes()
Assert.Equal("123", resource.Values["auth.password"]);
}
- [Fact]
- public void WithCrdWaitRetrySetsRetryConfiguration()
- {
- using var builder = TestDistributedApplicationBuilder.Create();
-
- var cluster = builder.AddKindCluster("test-cluster");
- cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
- .WithCrdWaitRetry(maxAttempts: 3, backoff: TimeSpan.FromSeconds(7), crdWaitTimeout: TimeSpan.FromSeconds(42));
-
- using var app = builder.Build();
- var appModel = app.Services.GetRequiredService();
-
- var resource = Assert.Single(appModel.Resources.OfType());
- Assert.Equal(3, resource.CrdWaitRetryMaxAttempts);
- Assert.Equal(TimeSpan.FromSeconds(7), resource.CrdWaitRetryBackoff);
- Assert.Equal(TimeSpan.FromSeconds(42), resource.CrdWaitRetryTimeout);
- }
-
- [Fact]
- public void WithCrdWaitRetryUsesDefaultBackoff()
- {
- using var builder = TestDistributedApplicationBuilder.Create();
-
- var cluster = builder.AddKindCluster("test-cluster");
- cluster.AddHelmChart("redis", "oci://registry-1.docker.io/bitnamicharts/redis")
- .WithCrdWaitRetry();
-
- using var app = builder.Build();
- var appModel = app.Services.GetRequiredService();
-
- var resource = Assert.Single(appModel.Resources.OfType());
- Assert.Equal(3, resource.CrdWaitRetryMaxAttempts);
- Assert.Equal(KubectlTimeouts.DefaultCrdWaitRetryBackoff, resource.CrdWaitRetryBackoff);
- Assert.Equal(KubectlTimeouts.DefaultCrdWaitTimeout, resource.CrdWaitRetryTimeout);
- }
-
- [Fact]
- public void WithCrdWaitRetryRejectsLessThanTwoAttempts()
- {
- using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
-
- Assert.Throws(() =>
- cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(1, TimeSpan.FromSeconds(5)));
- }
-
- [Fact]
- public void WithCrdWaitRetryRejectsInvalidBackoff()
- {
- using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
-
- Assert.Throws(() =>
- cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.Zero));
- }
-
- [Fact]
- public void WithCrdWaitRetryRejectsInvalidCrdWaitTimeout()
- {
- using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
-
- Assert.Throws(() =>
- cluster.AddHelmChart("redis", "chart/ref").WithCrdWaitRetry(3, TimeSpan.FromSeconds(5), TimeSpan.Zero));
- }
-
[Fact]
public void WithHelmValuesFileAddsPath()
{
@@ -368,129 +304,78 @@ public void CreateInstallArgumentsPreservesArgumentBoundaries()
}
[Fact]
- public async Task InstallAsync_RetriesAfterWaitingForNewCrds()
+ public async Task InstallAsync_PublishesCrdsNewlyObservedAfterSuccessfulInstall()
{
- var cluster = new KindClusterResource("cluster");
- var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
- {
- CrdWaitRetryMaxAttempts = 3,
- CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
- };
- var processRunner = new FakeProcessRunner();
- processRunner.Results.Enqueue(new(0, "", "")); // kubectl get crd baseline
- processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first"));
- processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "")); // kubectl get crd after failure
- processRunner.Results.Enqueue(new(0, "", "")); // kubectl wait
- processRunner.Results.Enqueue(new(0, "release installed", "")); // retry succeeds
- var delays = new List();
- var manager = new HelmManager(
- processRunner,
- (delay, _) =>
- {
- delays.Add(delay);
- return Task.CompletedTask;
- });
+ var resource = new KindHelmChartResource("redis", "chart/ref", new KindClusterResource("cluster"));
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/existing.example.com", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, """
+ customresourcedefinition.apiextensions.k8s.io/existing.example.com
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ """, ""));
using var loggerFactory = LoggerFactory.Create(_ => { });
- await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+ await new HelmManager(runner).InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
- Assert.Equal(5, processRunner.Commands.Count);
- Assert.Equal("kubectl", processRunner.Commands[0].FileName);
- Assert.Contains("get crd -o name", processRunner.Commands[0].Arguments);
- Assert.Equal("helm", processRunner.Commands[1].FileName);
- Assert.Equal("kubectl", processRunner.Commands[2].FileName);
- Assert.Contains("get crd -o name", processRunner.Commands[2].Arguments);
- Assert.Equal("kubectl", processRunner.Commands[3].FileName);
- Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[3].Arguments);
- Assert.Equal("helm", processRunner.Commands[4].FileName);
- Assert.Equal([TimeSpan.FromSeconds(2)], delays);
+ var crdNames = KindDeploymentOutcomes.GetOrCreate(resource).CrdNames;
+ Assert.Equal(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ crdNames);
+ Assert.Equal(["kubectl", "helm", "kubectl"], runner.Commands.Select(command => command.FileName));
}
[Fact]
- public async Task InstallAsync_UsesConfiguredCrdWaitTimeoutBetweenRetries()
+ public async Task InstallAsync_FailsBeforeHelmWhenCrdBaselineCannotBeQueried()
{
- var cluster = new KindClusterResource("cluster");
- var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
- {
- CrdWaitRetryMaxAttempts = 2,
- CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
- CrdWaitRetryTimeout = TimeSpan.FromSeconds(42),
- };
- var processRunner = new FakeProcessRunner();
- processRunner.Results.Enqueue(new(0, "", ""));
- processRunner.Results.Enqueue(new(1, "", "no matches for kind \"Widget\" in version \"widgets.example.com/v1\"; ensure CRDs are installed first"));
- processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
- processRunner.Results.Enqueue(new(0, "", ""));
- processRunner.Results.Enqueue(new(0, "release installed", ""));
- var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask);
+ var resource = new KindHelmChartResource("redis", "chart/ref", new KindClusterResource("cluster"));
+ var runner = new FakeProcessRunner { NextResult = new(1, "", "forbidden") };
using var loggerFactory = LoggerFactory.Create(_ => { });
- await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+ var error = await Assert.ThrowsAsync(() =>
+ new HelmManager(runner).InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken));
- Assert.Contains("--timeout=42s", processRunner.Commands[3].Arguments);
+ Assert.Contains("Failed to query custom resource definitions", error.Message);
+ Assert.DoesNotContain(runner.Commands, command => command.FileName == "helm");
+ Assert.False(resource.TryGetLastAnnotation(out _));
}
[Fact]
- public async Task InstallAsync_DoesNotRetryByDefault()
+ public async Task InstallAsync_DoesNotDiscoverCrdsAfterFailedHelmInstall()
{
- var cluster = new KindClusterResource("cluster");
- var resource = new KindHelmChartResource("redis", "chart/ref", cluster);
- var processRunner = new FakeProcessRunner();
- processRunner.Results.Enqueue(new(1, "", "release failed"));
- var manager = new HelmManager(processRunner, static (_, _) => Task.CompletedTask);
+ var resource = new KindHelmChartResource("redis", "chart/ref", new KindClusterResource("cluster"));
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(1, "", "release failed"));
using var loggerFactory = LoggerFactory.Create(_ => { });
- var ex = await Assert.ThrowsAsync(
- () => manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+ await Assert.ThrowsAsync(() =>
+ new HelmManager(runner).InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken));
- Assert.Contains("Failed to install Helm chart", ex.Message);
- Assert.Single(processRunner.Commands);
+ Assert.False(resource.TryGetLastAnnotation(out _));
+ Assert.Equal(["kubectl", "helm"], runner.Commands.Select(command => command.FileName));
}
[Fact]
- public async Task InstallAsync_RetriesExplicitlyConfiguredFailuresEvenWithoutNewCrds()
+ public async Task InstallAsync_FailedInstallRetainsLastSuccessfulOutcome()
{
- var cluster = new KindClusterResource("cluster");
- var resource = new KindHelmChartResource("redis", "chart/ref", cluster)
- {
- CrdWaitRetryMaxAttempts = 3,
- CrdWaitRetryBackoff = TimeSpan.FromSeconds(2),
- };
- var processRunner = new FakeProcessRunner();
- processRunner.Results.Enqueue(new(0, "", ""));
- processRunner.Results.Enqueue(new(1, "", "release failed"));
- processRunner.Results.Enqueue(new(0, "", ""));
- processRunner.Results.Enqueue(new(1, "", "release still failed"));
- processRunner.Results.Enqueue(new(0, "", ""));
- processRunner.Results.Enqueue(new(0, "release installed", ""));
- var delays = new List();
- var manager = new HelmManager(processRunner, (delay, _) =>
- {
- delays.Add(delay);
- return Task.CompletedTask;
- });
+ var resource = new KindHelmChartResource("redis", "chart/ref", new KindClusterResource("cluster"));
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ runner.Results.Enqueue(new(1, "", "installation failed"));
using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new HelmManager(runner);
- await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
-
- Assert.Equal(6, processRunner.Commands.Count);
- Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "helm"));
- Assert.Equal(3, processRunner.Commands.Count(command => command.FileName == "kubectl"));
- Assert.Equal([TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(4)], delays);
- }
-
- [Fact]
- public void ComputeRetryBackoffDoublesPerFailure()
- {
- Assert.Equal(TimeSpan.FromSeconds(5), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 1));
- Assert.Equal(TimeSpan.FromSeconds(10), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 2));
- Assert.Equal(TimeSpan.FromSeconds(20), HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 3));
- }
+ await manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ var crdNames = KindDeploymentOutcomes.GetOrCreate(resource).CrdNames;
+ Assert.Equal(["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"], crdNames);
- [Fact]
- public void ComputeRetryBackoffSaturatesInsteadOfOverflowing()
- {
- Assert.Equal(TimeSpan.MaxValue, HelmManager.ComputeRetryBackoff(TimeSpan.FromSeconds(5), 64));
+ await Assert.ThrowsAsync(() =>
+ manager.InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken));
+ Assert.Same(crdNames, KindDeploymentOutcomes.GetOrCreate(resource).CrdNames);
}
// ── Null-check tests ─────────────────────────────────────────────────
@@ -576,17 +461,6 @@ public void WithHelmValuesFileShouldThrowWhenBuilderIsNull()
Assert.Equal(nameof(builder), exception.ParamName);
}
- [Fact]
- public void WithCrdWaitRetryShouldThrowWhenBuilderIsNull()
- {
- IResourceBuilder builder = null!;
-
- var action = () => builder.WithCrdWaitRetry();
-
- var exception = Assert.Throws(action);
- Assert.Equal(nameof(builder), exception.ParamName);
- }
-
[Fact]
public void WithNamespaceShouldThrowWhenBuilderIsNull()
{
@@ -634,7 +508,132 @@ public void AddHelmChartRegistersHealthCheck()
var helmResource = Assert.Single(appModel.Resources.OfType());
var healthCheckAnnotations = helmResource.Annotations.OfType();
- Assert.NotEmpty(healthCheckAnnotations);
+ Assert.Contains(healthCheckAnnotations, annotation => annotation.Key == "helm_redis");
+ }
+
+ [Fact]
+ public void AddHelmChartKeepsOnlyWorkloadHealthCheck()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster").AddHelmChart("redis", "chart/ref").Resource;
+
+ using var app = builder.Build();
+
+ Assert.Equal("helm_redis", Assert.Single(resource.Annotations.OfType()).Key);
+ }
+
+ [Fact]
+ public async Task HelmPostApplyCheckWaitsForEveryNewCrd()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/existing.example.com", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, """
+ customresourcedefinition.apiextensions.k8s.io/existing.example.com
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ customresourcedefinition.apiextensions.k8s.io/gadgets.example.com
+ """, ""));
+ var kubernetes = FakeCrdClient.Established("widgets.example.com", "gadgets.example.com");
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster").AddHelmChart("redis", "chart/ref").Resource;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+
+ await new HelmManager(runner).InstallAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, runner.Commands.Count);
+ Assert.Equal(["gadgets.example.com", "widgets.example.com"], kubernetes.ReadNames.Order());
+ Assert.All(runner.Commands.Where(command => command.FileName == "kubectl"),
+ command => Assert.StartsWith("get crd ", command.Arguments));
+ }
+
+ [Fact]
+ public async Task HelmBestEffortCrdReadFailureAllowsPostApplyToComplete()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((_, _) =>
+ Task.FromException(new HttpRequestException("connection refused")));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var chart = builder.AddKindCluster("test-cluster").AddHelmChart("redis", "chart/ref");
+ Assert.Same(chart, chart.WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort));
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+
+ await new HelmManager(runner).InstallAsync(chart.Resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await app.Services.GetRequiredService().RunAsync(chart.Resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ Assert.Equal(3, runner.Commands.Count);
+ }
+
+ [Fact]
+ public void WithCrdWaitConfiguresHelmPolicyWithInferredResourceType()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var chart = builder.AddKindCluster("test-cluster").AddHelmChart("redis", "chart/ref");
+
+ Assert.Same(chart, chart.WithCrdWait(options => options.Timeout = TimeSpan.FromMilliseconds(500)));
+ Assert.True(chart.Resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(1), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.Fail, policy.Options.FailureBehavior);
+ }
+
+ [Fact]
+ public async Task GetCrdSnapshotStopsHungKubectlProbe()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Delays.Enqueue(TimeSpan.FromHours(1));
+ var manager = new KubectlManager(runner, apiProbeTimeout: TimeSpan.FromMilliseconds(50));
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(3));
+
+ await Assert.ThrowsAsync(() =>
+ manager.GetCustomResourceDefinitionsAsync(
+ new KindClusterResource("cluster").KubeconfigPath, loggerFactory.CreateLogger("test"), cts.Token));
+ }
+
+ [Fact]
+ public async Task HelmCrdFailureFailsToStartWithoutRunning()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((_, _) => Task.FromException(
+ new InvalidOperationException("CRD read failed")));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ var cluster = builder.AddResource(new KindClusterResource("test-cluster"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Kind Cluster",
+ State = KnownResourceStates.Running,
+ Properties = [],
+ });
+ cluster.AddHelmChart("redis", "chart/ref");
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(8));
+
+ var startTask = app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ await notifications.WaitForResourceAsync("redis", KnownResourceStates.FailedToStart, cts.Token);
+ await startTask;
+
+ Assert.True(notifications.TryGetCurrentState("redis", out var current));
+ Assert.Equal(KnownResourceStates.FailedToStart, current.Snapshot.State?.Text);
+ Assert.Equal(3, runner.Commands.Count);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
}
[Fact]
@@ -658,4 +657,5 @@ public void AddHelmChartRegistersUniqueHealthCheckPerResource()
Assert.NotEmpty(healthCheckAnnotations);
}
}
+
}
\ No newline at end of file
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
index e2b073c60..f1e0a0a92 100644
--- a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindManifestTests.cs
@@ -5,7 +5,12 @@
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Utils;
using Microsoft.Extensions.Logging;
+using k8s;
+using k8s.Autorest;
+using k8s.Models;
+using System.Net;
using System.ComponentModel;
+using System.Diagnostics;
namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
@@ -65,6 +70,364 @@ public void AddManifestFromContentCreatesResource()
Assert.False(resource.IsKustomize);
}
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public void AddManifestRegistersPostApplyCheckWithoutRecurringHealth(bool inline)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddKindCluster("test-cluster");
+ if (inline)
+ {
+ cluster.AddManifestFromContent("crds", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: test");
+ }
+ else
+ {
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ }
+
+ using var app = builder.Build();
+ var model = app.Services.GetRequiredService();
+ var resource = Assert.Single(model.Resources.OfType());
+ Assert.Empty(resource.Annotations.OfType());
+ Assert.Single(app.Services.GetServices());
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheckWaitsForAllAppliedCrds()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, """
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ namespace/example
+ customresourcedefinition.apiextensions.k8s.io/gadgets.example.com
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ """, ""));
+ var kubernetes = FakeCrdClient.Established("widgets.example.com", "gadgets.example.com");
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(runner);
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(2, runner.Commands.Count);
+ Assert.Equal(2, kubernetes.ReadCount);
+ Assert.Equal(["gadgets.example.com", "widgets.example.com"], kubernetes.ReadNames.Order());
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheckOnlyRereadsRemainingCrdsUntilEstablished()
+ {
+ var gadgetsReads = 0;
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(name switch
+ {
+ "widgets.example.com" => FakeCrdClient.Definition(name, established: true),
+ "gadgets.example.com" => FakeCrdClient.Definition(
+ name, established: Interlocked.Increment(ref gadgetsReads) > 1),
+ _ => throw new InvalidOperationException($"Unexpected CRD: {name}"),
+ }));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ var factoryCalls = 0;
+ builder.Services.AddSingleton>(_ => _ =>
+ {
+ Interlocked.Increment(ref factoryCalls);
+ return kubernetes.Client;
+ });
+ using var app = builder.Build();
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ [
+ "customresourcedefinition.apiextensions.k8s.io/widgets.example.com",
+ "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com",
+ ];
+
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(3, kubernetes.ReadCount);
+ Assert.Equal(1, kubernetes.ReadNames.Count(name => name == "widgets.example.com"));
+ Assert.Equal(2, kubernetes.ReadNames.Count(name => name == "gadgets.example.com"));
+ Assert.Equal(1, factoryCalls);
+ Assert.Equal(1, kubernetes.DisposeCount);
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheckRetriesNotFoundUntilCrdIsEstablished()
+ {
+ var reads = 0;
+ var kubernetes = new FakeCrdClient((name, _) =>
+ {
+ if (Interlocked.Increment(ref reads) == 1)
+ {
+ return Task.FromException(new HttpOperationException
+ {
+ Response = new HttpResponseMessageWrapper(
+ new HttpResponseMessage(HttpStatusCode.NotFound), "CRD not found"),
+ });
+ }
+
+ return Task.FromResult(FakeCrdClient.Definition(name, established: true));
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"];
+
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(["widgets.example.com", "widgets.example.com"], kubernetes.ReadNames);
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheckRejectsNamesAcceptedFalseWithoutWaitingForTimeout()
+ {
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(
+ FakeCrdClient.Definition(name, established: false, namesAccepted: false)));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"];
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+
+ var error = await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, cts.Token));
+
+ Assert.Contains("rejected", error.Message, StringComparison.OrdinalIgnoreCase);
+ Assert.Contains("widgets.example.com", error.Message);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheckReportsRejectedNameBeforeOtherReadCompletes()
+ {
+ TaskCompletionSource slowRead =
+ new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource enteredSlowRead = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ var kubernetes = new FakeCrdClient((name, _) =>
+ {
+ if (name == "gadgets.example.com")
+ {
+ enteredSlowRead.TrySetResult();
+ return slowRead.Task;
+ }
+
+ return Task.FromResult(FakeCrdClient.Definition(name, established: false, namesAccepted: false));
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ [
+ "customresourcedefinition.apiextensions.k8s.io/widgets.example.com",
+ "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com",
+ ];
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(5));
+ var check = app.Services.GetRequiredService().RunAsync(resource, cts.Token);
+ try
+ {
+ await enteredSlowRead.Task.WaitAsync(cts.Token);
+ var error = await Assert.ThrowsAsync(() => check.WaitAsync(cts.Token));
+ Assert.Contains("widgets.example.com", error.Message);
+ Assert.Equal(0, kubernetes.DisposeCount);
+ }
+ finally
+ {
+ slowRead.TrySetResult(FakeCrdClient.Definition("gadgets.example.com", established: true));
+ }
+
+ Assert.True(SpinWait.SpinUntil(() => kubernetes.DisposeCount == 1, TimeSpan.FromSeconds(5)));
+ Assert.Equal(2, kubernetes.ReadCount);
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheck_DistinguishesPendingFromSuccessfulApplyWithoutCrds()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "namespace/example", ""));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("namespace", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: example").Resource;
+ builder.Services.AddSingleton(runner);
+ using var app = builder.Build();
+ var outcomes = KindDeploymentOutcomes.GetOrCreate(resource);
+ Assert.Null(outcomes.CrdNames);
+
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ await new KubectlManager(runner).ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+
+ Assert.Empty(outcomes.CrdNames!);
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+ Assert.Equal(2, runner.Commands.Count);
+ }
+
+ [Fact]
+ public async Task ManifestCrdCheckRejectsMissingApplyOutcome()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("manifest", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: example").Resource;
+ using var app = builder.Build();
+
+ var error = await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken));
+
+ Assert.Contains("has not completed applying", error.Message);
+ }
+
+ [Fact]
+ public async Task ManifestCrdCheckDoesNotCreateMissingOutcomeAnnotation()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("manifest", "apiVersion: v1\nkind: Namespace\nmetadata:\n name: example").Resource;
+ var annotation = KindDeploymentOutcomes.GetOrCreate(resource);
+ resource.Annotations.Remove(annotation);
+ using var app = builder.Build();
+
+ var error = await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken));
+
+ Assert.Contains("has not completed applying", error.Message);
+ Assert.False(resource.TryGetLastAnnotation(out _));
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheck_RetainsLastSuccessfulCrdsAfterFailedReapply()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(1, "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com", "forbidden"));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(runner);
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await Assert.ThrowsAsync(
+ () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken));
+ Assert.Equal(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames);
+ Assert.Equal(4, runner.Commands.Count);
+ }
+
+ [Fact]
+ public async Task ManifestCrdFailureFailsToStartWithoutRunning()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(
+ FakeCrdClient.Definition(name, established: false)));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ var cluster = builder.AddResource(new KindClusterResource("test-cluster"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Kind Cluster",
+ State = KnownResourceStates.Running,
+ Properties = [],
+ });
+ cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options => options.Timeout = TimeSpan.FromSeconds(1));
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(30));
+
+ var startTask = app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ await notifications.WaitForResourceAsync("crds", KnownResourceStates.FailedToStart, cts.Token);
+ await startTask;
+
+ Assert.True(notifications.TryGetCurrentState("crds", out var current));
+ Assert.Equal(KnownResourceStates.FailedToStart, current.Snapshot.State?.Text);
+ Assert.Equal(2, runner.Commands.Count);
+ Assert.True(kubernetes.ReadCount > 0);
+ }
+
+ [Fact]
+ public async Task WaitForManifestHoldsDependentUntilCrdsAreEstablished()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ TaskCompletionSource waitingForCrd = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource establishCrd = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ var kubernetes = new FakeCrdClient(async (name, cancellationToken) =>
+ {
+ waitingForCrd.TrySetResult(true);
+ await establishCrd.Task.WaitAsync(cancellationToken);
+ return FakeCrdClient.Definition(name, established: true);
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ var cluster = builder.AddResource(new KindClusterResource("test-cluster"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Kind Cluster",
+ State = KnownResourceStates.Running,
+ Properties = [],
+ });
+ var crds = cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ var dependent = builder.AddResource(new ManifestDependentResource("consumer"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Manifest Dependent",
+ State = KnownResourceStates.NotStarted,
+ Properties = [],
+ })
+ .WaitFor(crds);
+ dependent.OnInitializeResource(async (resource, e, ct) =>
+ {
+ await e.Eventing.PublishAsync(new BeforeResourceStartedEvent(resource, e.Services), ct);
+ await e.Notifications.PublishUpdateAsync(resource, state => state with { State = KnownResourceStates.Running });
+ });
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(30));
+
+ var startTask = app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ try
+ {
+ await waitingForCrd.Task.WaitAsync(cts.Token);
+ await notifications.WaitForResourceAsync("consumer", KnownResourceStates.Waiting, cts.Token);
+ Assert.Equal(2, runner.Commands.Count);
+ }
+ finally
+ {
+ establishCrd.TrySetResult(true);
+ }
+
+ await notifications.WaitForResourceHealthyAsync("crds", cts.Token);
+ await notifications.WaitForResourceHealthyAsync("consumer", cts.Token);
+ await startTask;
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ }
+
[Fact]
public void AddManifestThrowsOnNullBuilder()
{
@@ -425,84 +788,256 @@ public void WithApplyTimeoutRejectsMoreThanOneHour()
}
[Fact]
- public void WithCrdWaitTimeoutSetsCrdWaitTimeout()
+ public void CrdWaitOptionsUseExpectedDefaults()
{
- using var builder = TestDistributedApplicationBuilder.Create();
+ var options = new CrdWaitOptions();
- var cluster = builder.AddKindCluster("test-cluster");
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitTimeout(TimeSpan.FromSeconds(45));
+ Assert.Equal(TimeSpan.FromMinutes(5), options.Timeout);
+ Assert.Equal(CrdWaitBehavior.Fail, options.FailureBehavior);
+ }
- using var app = builder.Build();
- var appModel = app.Services.GetRequiredService();
+ [Fact]
+ public void CrdWaitOptionsTimeoutRoundsFractionalSecondsAtAssignment()
+ {
+ var options = new CrdWaitOptions
+ {
+ Timeout = TimeSpan.FromMilliseconds(500),
+ };
- var resource = Assert.Single(appModel.Resources.OfType());
- Assert.Equal(TimeSpan.FromSeconds(45), resource.CrdWaitTimeout);
+ Assert.Equal(TimeSpan.FromSeconds(1), options.Timeout);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(3_600_001)]
+ public void CrdWaitOptionsTimeoutRejectsInvalidAssignment(long milliseconds)
+ {
+ var options = new CrdWaitOptions();
+
+ Assert.Throws(() =>
+ options.Timeout = TimeSpan.FromMilliseconds(milliseconds));
+ Assert.Equal(TimeSpan.FromMinutes(5), options.Timeout);
}
[Fact]
- public void WithCrdWaitTimeoutRejectsZero()
+ public void WithCrdWaitUsesDefaultsForNewPolicy()
{
using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
- Assert.Throws(() =>
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitTimeout(TimeSpan.Zero));
+ manifest.WithCrdWait(_ => { });
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromMinutes(5), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.Fail, policy.Options.FailureBehavior);
}
[Fact]
- public void WithCrdWaitTimeoutRejectsNegative()
+ public void WithCrdWaitInvokesCallbackOnce()
{
using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ var callbackCount = 0;
- Assert.Throws(() =>
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitTimeout(TimeSpan.FromSeconds(-1)));
+ manifest.WithCrdWait(_ => callbackCount++);
+
+ Assert.Equal(1, callbackCount);
}
[Fact]
- public void WithCrdWaitTimeoutRoundsSubSecondUpToOneSecond()
+ public void WithCrdWaitConfiguresManifestPolicyWithInferredResourceType()
{
using var builder = TestDistributedApplicationBuilder.Create();
var cluster = builder.AddKindCluster("test-cluster");
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitTimeout(TimeSpan.FromMilliseconds(500));
+ var manifest = cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ Assert.Same(manifest, manifest.WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromSeconds(45);
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ }));
using var app = builder.Build();
var appModel = app.Services.GetRequiredService();
var resource = Assert.Single(appModel.Resources.OfType());
- Assert.Equal(TimeSpan.FromSeconds(1), resource.CrdWaitTimeout);
+ Assert.True(resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(45), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, policy.Options.FailureBehavior);
}
[Fact]
- public void WithCrdWaitTimeoutRejectsMoreThanOneHour()
+ public void WithCrdWaitAnnotationStoresExactCallbackOptionsInstance()
{
using var builder = TestDistributedApplicationBuilder.Create();
- var cluster = builder.AddKindCluster("test-cluster");
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ CrdWaitOptions? configuredOptions = null;
+
+ manifest.WithCrdWait(options =>
+ {
+ configuredOptions = options;
+ options.Timeout = TimeSpan.FromMilliseconds(500);
+ });
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ Assert.NotNull(configuredOptions);
+ Assert.Same(configuredOptions, policy.Options);
+ Assert.Equal(TimeSpan.FromSeconds(1), configuredOptions.Timeout);
+ }
+
+ [Fact]
+ public void CapturedCrdWaitOptionsRejectInvalidMutationAfterConfiguration()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ CrdWaitOptions? configuredOptions = null;
+ manifest.WithCrdWait(options =>
+ {
+ configuredOptions = options;
+ options.Timeout = TimeSpan.FromSeconds(45);
+ });
+ Assert.NotNull(configuredOptions);
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+
+ Assert.Throws(() => configuredOptions.Timeout = TimeSpan.Zero);
+
+ Assert.Same(configuredOptions, policy.Options);
+ Assert.Equal(TimeSpan.FromSeconds(45), policy.Options.Timeout);
+ }
+
+ [Fact]
+ public void FailedWithCrdWaitConfigurationKeepsOriginalOptionsReference()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+ CrdWaitOptions? originalOptions = null;
+ manifest.WithCrdWait(options =>
+ {
+ originalOptions = options;
+ options.Timeout = TimeSpan.FromSeconds(45);
+ });
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ CrdWaitOptions? failedOptions = null;
+
+ Assert.Throws(() => manifest.WithCrdWait(options =>
+ {
+ failedOptions = options;
+ options.Timeout = TimeSpan.FromSeconds(30);
+ throw new InvalidOperationException("Configuration failed.");
+ }));
+
+ Assert.NotNull(originalOptions);
+ Assert.NotNull(failedOptions);
+ Assert.NotSame(originalOptions, failedOptions);
+ Assert.Same(originalOptions, policy.Options);
+ Assert.Equal(TimeSpan.FromSeconds(45), policy.Options.Timeout);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ [InlineData(3_600_001)]
+ public void WithCrdWaitRejectsInvalidTimeoutWithoutAttachingPolicy(long milliseconds)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
Assert.Throws(() =>
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitTimeout(TimeSpan.MaxValue));
+ manifest.WithCrdWait(options => options.Timeout = TimeSpan.FromMilliseconds(milliseconds)));
+ Assert.False(manifest.Resource.TryGetLastAnnotation(out _));
}
[Fact]
- public void WithCrdWaitBehaviorSetsCrdWaitBehavior()
+ public void WithCrdWaitRoundsFractionalSecondsUp()
{
using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
- var cluster = builder.AddKindCluster("test-cluster");
- cluster.AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
- .WithCrdWaitBehavior(CrdWaitBehavior.BestEffort);
+ manifest.WithCrdWait(options => options.Timeout = TimeSpan.FromMilliseconds(500));
- using var app = builder.Build();
- var appModel = app.Services.GetRequiredService();
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(1), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.Fail, policy.Options.FailureBehavior);
+ }
- var resource = Assert.Single(appModel.Resources.OfType());
- Assert.Equal(CrdWaitBehavior.BestEffort, resource.CrdWaitBehavior);
+ [Fact]
+ public void RepeatedWithCrdWaitCallsPreserveUnspecifiedValues()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+
+ manifest.WithCrdWait(options => options.Timeout = TimeSpan.FromSeconds(45));
+ manifest.WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort);
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(45), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, policy.Options.FailureBehavior);
+ }
+
+ [Fact]
+ public void WithCrdWaitCallbackFailureDoesNotChangeExistingPolicy()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromSeconds(45);
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ });
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var originalPolicy));
+ var originalOptions = originalPolicy.Options;
+
+ Assert.Throws(() => manifest.WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromSeconds(30);
+ options.FailureBehavior = CrdWaitBehavior.Fail;
+ Assert.Equal(TimeSpan.FromSeconds(45), originalPolicy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, originalPolicy.Options.FailureBehavior);
+ throw new InvalidOperationException("Configuration failed.");
+ }));
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var currentPolicy));
+ Assert.Same(originalPolicy, currentPolicy);
+ Assert.Same(originalOptions, currentPolicy.Options);
+ Assert.Equal(TimeSpan.FromSeconds(45), currentPolicy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, currentPolicy.Options.FailureBehavior);
+ }
+
+ [Fact]
+ public void WithCrdWaitValidationFailureDoesNotChangeExistingPolicy()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromSeconds(45);
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ });
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var originalPolicy));
+ var originalOptions = originalPolicy.Options;
+
+ Assert.Throws(() => manifest.WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.Zero;
+ options.FailureBehavior = CrdWaitBehavior.Fail;
+ }));
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var currentPolicy));
+ Assert.Same(originalPolicy, currentPolicy);
+ Assert.Same(originalOptions, currentPolicy.Options);
+ Assert.Equal(TimeSpan.FromSeconds(45), currentPolicy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, currentPolicy.Options.FailureBehavior);
}
[Fact]
@@ -552,13 +1087,41 @@ public void DefaultClusterReadyTimeoutIsSixtySeconds()
}
[Fact]
- public void DefaultCrdWaitSettingsFailAfterFiveMinutes()
+ public void LegacyManifestCrdWaitPropertiesSharePolicy()
{
- var cluster = new KindClusterResource("cluster");
- var resource = new K8sManifestResource("crds", "./crds.yaml", cluster);
+ var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("cluster"));
+#pragma warning disable CS0618 // Exercise the shipped manifest API.
Assert.Equal(TimeSpan.FromMinutes(5), resource.CrdWaitTimeout);
Assert.Equal(CrdWaitBehavior.Fail, resource.CrdWaitBehavior);
+ Assert.False(resource.TryGetLastAnnotation(out _));
+ resource.CrdWaitTimeout = TimeSpan.FromMilliseconds(500);
+ resource.CrdWaitBehavior = (CrdWaitBehavior)42;
+ Assert.Throws(() => resource.CrdWaitTimeout = TimeSpan.Zero);
+#pragma warning restore CS0618
+
+ Assert.True(resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(1), policy.Options.Timeout);
+ Assert.Equal((CrdWaitBehavior)42, policy.Options.FailureBehavior);
+ }
+
+ [Fact]
+ public void LegacyManifestCrdWaitMethodsSharePolicy()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var manifest = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"));
+
+#pragma warning disable CS0618 // Exercise the shipped manifest overloads.
+ Assert.Same(manifest, KindManifestResourceBuilderExtensions.WithCrdWaitTimeout(manifest, TimeSpan.FromMilliseconds(500)));
+ Assert.Same(manifest, KindManifestResourceBuilderExtensions.WithCrdWaitBehavior(manifest, CrdWaitBehavior.BestEffort));
+ Assert.Throws(() =>
+ KindManifestResourceBuilderExtensions.WithCrdWaitTimeout(manifest, TimeSpan.Zero));
+#pragma warning restore CS0618
+
+ Assert.True(manifest.Resource.TryGetLastAnnotation(out var policy));
+ Assert.Equal(TimeSpan.FromSeconds(1), policy.Options.Timeout);
+ Assert.Equal(CrdWaitBehavior.BestEffort, policy.Options.FailureBehavior);
}
[Fact]
@@ -817,104 +1380,232 @@ public void CreateApplyArguments_WithFieldManager_IncludesFieldManagerFlag()
Assert.Contains("my-tool", args);
}
- [Fact]
- public void CreateWaitArguments_ProducesExpectedShape()
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void CreateKubectlArguments_RejectWhitespaceKubeconfigPath(string kubeconfigPath)
{
- var args = KubectlManager.CreateWaitArguments(
- ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com", "customresourcedefinition.apiextensions.k8s.io/gadgets.example.com"],
- "C:\\kube\\config.yaml",
- TimeSpan.FromMinutes(5));
-
- Assert.Equal("wait", args[0]);
- Assert.Equal("--for=condition=Established", args[1]);
- Assert.Contains("customresourcedefinition.apiextensions.k8s.io/widgets.example.com", args);
- Assert.Contains("customresourcedefinition.apiextensions.k8s.io/gadgets.example.com", args);
- Assert.Contains("--timeout=300s", args);
- Assert.Contains("--kubeconfig=C:\\kube\\config.yaml", args);
+ Assert.Throws(() => KubectlManager.CreateClusterInfoArguments(kubeconfigPath));
+ Assert.Throws(() => KubectlManager.CreateGetCrdsArguments(kubeconfigPath));
}
[Fact]
- public void CreateWaitArguments_RoundsSubSecondTimeoutUpToOneSecond()
+ public async Task ManifestPostApplyCheck_BestEffortAllowsUnverifiedReadiness()
{
- var args = KubectlManager.CreateWaitArguments(
- ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
- "C:\\kube\\config.yaml",
- TimeSpan.FromMilliseconds(500));
+ var processRunner = new FakeProcessRunner();
+ processRunner.Results.Enqueue(new(0, "cluster is running", ""));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(
+ FakeCrdClient.Definition(name, established: false)));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options =>
+ {
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ options.Timeout = TimeSpan.FromSeconds(1);
+ }).Resource;
+ builder.Services.AddSingleton(processRunner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ var manager = new KubectlManager(processRunner);
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
- Assert.Contains("--timeout=1s", args);
+ Assert.Equal(2, processRunner.Commands.Count);
+ Assert.True(kubernetes.ReadCount > 0);
}
[Theory]
- [InlineData("")]
- [InlineData(" ")]
- public void CreateKubectlArguments_RejectWhitespaceKubeconfigPath(string kubeconfigPath)
+ [InlineData(CrdWaitBehavior.Fail)]
+ [InlineData(CrdWaitBehavior.BestEffort)]
+ public async Task ManifestPostApplyCheck_HandlesTransportFailureAccordingToPolicy(CrdWaitBehavior behavior)
{
- Assert.Throws(() =>
- KubectlManager.CreateWaitArguments(
- ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
- kubeconfigPath,
- TimeSpan.FromSeconds(1)));
- Assert.Throws(() => KubectlManager.CreateClusterInfoArguments(kubeconfigPath));
- Assert.Throws(() => KubectlManager.CreateGetCrdsArguments(kubeconfigPath));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("crds", "kind: CustomResourceDefinition")
+ .WithCrdWait(options => options.FailureBehavior = behavior).Resource;
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"];
+ var kubernetes = new FakeCrdClient((_, _) =>
+ Task.FromException(new HttpRequestException("connection refused")));
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+
+ if (behavior == CrdWaitBehavior.BestEffort)
+ {
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+ }
+ else
+ {
+ var error = await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken));
+ Assert.Contains("connection refused", error.Message);
+ }
+
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
}
[Fact]
- public async Task ApplyAsync_WaitsForAppliedCrdsBestEffort()
+ public async Task ApplyAsync_DoesNotWaitForAppliedCrds()
{
var processRunner = new FakeProcessRunner();
processRunner.Results.Enqueue(new(0, "cluster is running", ""));
- processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
- processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition"));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
using var loggerFactory = LoggerFactory.Create(_ => { });
var manager = new KubectlManager(processRunner);
- var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"))
- {
- CrdWaitBehavior = CrdWaitBehavior.BestEffort,
- };
+ var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"));
await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
- Assert.Equal(3, processRunner.Commands.Count);
- Assert.Contains("cluster-info", processRunner.Commands[0].Arguments);
- Assert.Contains("apply -f ./crds.yaml", processRunner.Commands[1].Arguments);
- Assert.Contains("wait --for=condition=Established customresourcedefinition.apiextensions.k8s.io/widgets.example.com", processRunner.Commands[2].Arguments);
+ Assert.Equal(2, processRunner.Commands.Count);
+ Assert.Contains("--output=name", processRunner.Commands[1].Arguments);
}
[Fact]
- public async Task ApplyAsync_FailsWhenCrdWaitFailsByDefault()
+ public async Task ManifestPostApplyCheck_StrictCrdWaitFailureThrows()
{
var processRunner = new FakeProcessRunner();
processRunner.Results.Enqueue(new(0, "cluster is running", ""));
- processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
- processRunner.Results.Enqueue(new(1, "", "timed out waiting for the condition"));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(
+ FakeCrdClient.Definition(name, established: false)));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options => options.Timeout = TimeSpan.FromSeconds(1)).Resource;
+ builder.Services.AddSingleton(processRunner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
using var loggerFactory = LoggerFactory.Create(_ => { });
var manager = new KubectlManager(processRunner);
- var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"));
- var ex = await Assert.ThrowsAsync(
- () => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ var error = await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken));
- Assert.Contains("Established", ex.Message);
- Assert.Equal(3, processRunner.Commands.Count);
+ Assert.Contains("Established", error.Message);
+ Assert.Equal(2, processRunner.Commands.Count);
+ Assert.True(kubernetes.ReadCount > 0);
}
[Fact]
- public async Task ApplyAsync_UsesConfiguredCrdWaitTimeout()
+ public async Task ManifestPostApplyCheck_UsesParentKubeconfigWhenCreatingClient()
{
var processRunner = new FakeProcessRunner();
processRunner.Results.Enqueue(new(0, "cluster is running", ""));
- processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com created", ""));
- processRunner.Results.Enqueue(new(0, "", ""));
+ processRunner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = FakeCrdClient.Established("widgets.example.com");
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml")).Resource;
+ builder.Services.AddSingleton(processRunner);
+ string? kubeconfigPath = null;
+ builder.Services.AddSingleton>(_ => path =>
+ {
+ kubeconfigPath = path;
+ return kubernetes.Client;
+ });
+ using var app = builder.Build();
using var loggerFactory = LoggerFactory.Create(_ => { });
var manager = new KubectlManager(processRunner);
- var resource = new K8sManifestResource("crds", "./crds.yaml", new KindClusterResource("test-cluster"))
+
+ await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ await app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken);
+
+ Assert.Equal(resource.Parent.KubeconfigPath, kubeconfigPath);
+ Assert.Equal(2, processRunner.Commands.Count);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ }
+
+ [Theory]
+ [InlineData(CrdWaitBehavior.Fail)]
+ [InlineData(CrdWaitBehavior.BestEffort)]
+ public async Task ManifestPostApplyCheck_StopsHungWaitAtConfiguredTimeout(CrdWaitBehavior behavior)
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient(async (name, token) =>
{
- CrdWaitTimeout = TimeSpan.FromSeconds(42),
- };
+ await Task.Delay(Timeout.InfiniteTimeSpan, token);
+ return FakeCrdClient.Definition(name, established: false);
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifest("crds", Path.Combine(AppContext.BaseDirectory, "crds.yaml"))
+ .WithCrdWait(options =>
+ {
+ options.Timeout = TimeSpan.FromSeconds(1);
+ options.FailureBehavior = behavior;
+ }).Resource;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = LoggerFactory.Create(_ => { });
+ await new KubectlManager(runner).ApplyAsync(resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(3));
- await manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None);
+ if (behavior == CrdWaitBehavior.BestEffort)
+ {
+ await app.Services.GetRequiredService().RunAsync(resource, cts.Token);
+ }
+ else
+ {
+ await Assert.ThrowsAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, cts.Token));
+ }
+ Assert.Equal(2, runner.Commands.Count);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ }
+
+ [Fact]
+ public async Task ManifestPostApplyCheck_RejectsEstablishedResponseAfterDeadline()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("crds", "kind: CustomResourceDefinition")
+ .WithCrdWait(options => options.Timeout = TimeSpan.FromSeconds(1)).Resource;
+ var outcomes = KindDeploymentOutcomes.GetOrCreate(resource);
+ outcomes.CrdNames =
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"];
+ CancellationToken? waitCancellationToken = null;
+ var kubernetes = new FakeCrdClient(async (name, token) =>
+ {
+ waitCancellationToken = token;
+ await Task.Delay(TimeSpan.FromMilliseconds(1250), TestContext.Current.CancellationToken);
+ return FakeCrdClient.Definition(name, established: true);
+ });
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
- Assert.Contains("--timeout=42s", processRunner.Commands[2].Arguments);
+ var stopwatch = Stopwatch.StartNew();
+ var exception = await Record.ExceptionAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, TestContext.Current.CancellationToken));
+ var elapsed = stopwatch.Elapsed;
+
+ if (exception?.GetType() != typeof(TimeoutException))
+ {
+ resource.TryGetLastAnnotation(out var policy);
+ var crdNames = outcomes.CrdNames ?? [];
+ var waitCancellationRequested = waitCancellationToken is { } token
+ ? token.IsCancellationRequested.ToString()
+ : "";
+
+ Assert.Fail($"""
+ Expected TimeoutException, but the post-apply check returned a different result.
+ Effective CRD wait policy: Timeout={policy?.Options.Timeout.ToString() ?? ""}, FailureBehavior={policy?.Options.FailureBehavior.ToString() ?? ""}
+ CRD outcomes: Count={crdNames.Count}, Names=[{string.Join(", ", crdNames)}]
+ Fake CRD client reads: {kubernetes.ReadCount}
+ RunAsync elapsed: {elapsed}
+ Wait cancellation requested: {waitCancellationRequested}
+ Actual exception:
+ {exception?.ToString() ?? ""}
+ """);
+ }
}
[Fact]
@@ -949,7 +1640,7 @@ public async Task ApplyAsync_ClusterInfoSlowFailuresRespectWallClockBudget()
processRunner,
static (_, _) => Task.CompletedTask,
clusterInfoMaxWait: TimeSpan.FromMilliseconds(100),
- clusterInfoProbeTimeout: TimeSpan.FromSeconds(1));
+ apiProbeTimeout: TimeSpan.FromSeconds(1));
var resource = new K8sManifestResource("manifest", "./manifest.yaml", new KindClusterResource("test-cluster"));
var started = DateTimeOffset.UtcNow;
@@ -1035,6 +1726,7 @@ public async Task ApplyAsync_IncludesStdoutWhenApplyFailsWithoutStderr()
() => manager.ApplyAsync(resource, loggerFactory.CreateLogger("test"), CancellationToken.None));
Assert.Contains("resource mapping not found", ex.Message);
+ Assert.False(resource.TryGetLastAnnotation(out _));
}
[Fact]
@@ -1096,6 +1788,8 @@ private static string CreateTestDirectory()
return directory;
}
+ private sealed class ManifestDependentResource(string name) : Resource(name), IResourceWithWaitSupport;
+
private sealed class ThrowingProcessRunner(Exception exception) : IProcessRunner
{
public Task RunAsync(
diff --git a/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPostApplyTests.cs b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPostApplyTests.cs
new file mode 100644
index 000000000..4c30f654d
--- /dev/null
+++ b/tests/CommunityToolkit.Aspire.Hosting.Kind.Tests/KindPostApplyTests.cs
@@ -0,0 +1,851 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Aspire.Hosting;
+using Aspire.Hosting.ApplicationModel;
+using Aspire.Hosting.Utils;
+using k8s;
+using k8s.Models;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.HealthChecks;
+using Microsoft.Extensions.Options;
+
+namespace CommunityToolkit.Aspire.Hosting.Kind.Tests;
+
+public class KindPostApplyTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task InitializerRunsRegisteredChecksInOrderBeforeRunning(bool helm)
+ {
+ var runner = new FakeProcessRunner();
+ if (helm)
+ {
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ }
+ else
+ {
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ }
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = FakeCrdClient.Established("widgets.example.com");
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ List observed = [];
+ TaskCompletionSource entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource release = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ builder.Services.AddSingleton(new DelegateCheck(async (deployed, token) =>
+ {
+ Assert.Same(resource, deployed);
+ Assert.True(token.CanBeCanceled);
+ Assert.Equal(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ KindDeploymentOutcomes.GetOrCreate(deployed).CrdNames);
+ observed.Add("first");
+ entered.SetResult();
+ await release.Task.WaitAsync(token);
+ }));
+ builder.Services.AddSingleton(new DelegateCheck((deployed, _) =>
+ {
+ Assert.Same(resource, deployed);
+ observed.Add("second");
+ return Task.CompletedTask;
+ }));
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+ var start = app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+
+ try
+ {
+ await entered.Task.WaitAsync(cts.Token);
+ Assert.True(notifications.TryGetCurrentState(resource.Name, out var current));
+ Assert.Equal(KnownResourceStates.Starting, current.Snapshot.State?.Text);
+ Assert.Equal(["first"], observed);
+ }
+ finally
+ {
+ release.TrySetResult();
+ }
+
+ await notifications.WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
+ await start;
+ Assert.Equal(["first", "second"], observed);
+ Assert.Equal(helm ? 1 : 0, runner.Commands.Count(command => command.FileName == "helm"));
+ Assert.True(runner.Commands.Count >= (helm ? 3 : 2));
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task InitializerWithoutChecksStillRunsAfterSuccessfulApply(bool helm)
+ {
+ var runner = new FakeProcessRunner();
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ builder.Services.AddSingleton(runner);
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+
+ await app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ await notifications.WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
+ Assert.NotNull(KindDeploymentOutcomes.GetOrCreate(resource).CrdNames);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task DiscoveredCrdsAreEstablishedBeforeRunningOnlyOnce(bool helm)
+ {
+ var runner = new FakeProcessRunner();
+ if (helm)
+ {
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/existing.example.com", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ runner.Results.Enqueue(new(0, """
+ customresourcedefinition.apiextensions.k8s.io/existing.example.com
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ customresourcedefinition.apiextensions.k8s.io/gadgets.example.com
+ """, ""));
+ }
+ else
+ {
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, """
+ customresourcedefinition.apiextensions.k8s.io/widgets.example.com
+ namespace/example
+ customresourcedefinition.apiextensions.k8s.io/gadgets.example.com
+ """, ""));
+ }
+ TaskCompletionSource enteredWidget = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource enteredGadget = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ TaskCompletionSource releaseGadget = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ var kubernetes = new FakeCrdClient(async (name, token) =>
+ {
+ if (name == "gadgets.example.com")
+ {
+ enteredGadget.TrySetResult();
+ await releaseGadget.Task.WaitAsync(token);
+ }
+ else if (name == "widgets.example.com")
+ {
+ enteredWidget.TrySetResult();
+ }
+ return FakeCrdClient.Definition(name, established: true);
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+ var start = app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+
+ try
+ {
+ await enteredGadget.Task.WaitAsync(cts.Token);
+ await enteredWidget.Task.WaitAsync(cts.Token);
+ Assert.True(notifications.TryGetCurrentState(resource.Name, out var pending));
+ Assert.Equal(KnownResourceStates.Starting, pending.Snapshot.State?.Text);
+ Assert.Equal(helm ? 3 : 2, runner.Commands.Count);
+ Assert.Equal(["gadgets.example.com", "widgets.example.com"], kubernetes.ReadNames.Order());
+ }
+ finally
+ {
+ releaseGadget.TrySetResult();
+ }
+
+ await notifications.WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
+ await start;
+ Assert.Equal(2, kubernetes.ReadCount);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task NoDiscoveredCrdsSkipsClient(bool helm)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ var runner = new FakeProcessRunner();
+ var kubernetes = FakeCrdClient.Established();
+ var created = 0;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ =>
+ {
+ Interlocked.Increment(ref created);
+ return kubernetes.Client;
+ });
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+
+ await app.StartAsync(cts.Token);
+ await app.Services.GetRequiredService()
+ .WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
+ Assert.Empty(KindDeploymentOutcomes.GetOrCreate(resource).CrdNames!);
+ Assert.Equal(0, kubernetes.ReadCount);
+ Assert.Equal(0, created);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task StrictCrdWaitFailurePreventsRunning(bool helm)
+ {
+ var runner = new FakeProcessRunner();
+ if (helm)
+ {
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ }
+ else
+ {
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ }
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((_, _) => Task.FromException(
+ new InvalidOperationException("CRD read failed")));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+
+ await app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ await notifications.WaitForResourceAsync(resource.Name, KnownResourceStates.FailedToStart, cts.Token);
+ var annotation = KindDeploymentOutcomes.GetOrCreate(resource);
+ Assert.Equal(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ annotation.CrdNames);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ Assert.Equal(helm ? 3 : 2, runner.Commands.Count);
+ }
+
+ [Theory]
+ [InlineData(false, false, false)]
+ [InlineData(false, true, false)]
+ [InlineData(false, true, true)]
+ [InlineData(true, false, false)]
+ [InlineData(true, true, false)]
+ [InlineData(true, true, true)]
+ public async Task ClientCreationFailureRespectsCrdWaitPolicy(bool helm, bool bestEffort, bool invalidConfig)
+ {
+ var runner = new FakeProcessRunner();
+ if (helm)
+ {
+ runner.Results.Enqueue(new(0, "", ""));
+ runner.Results.Enqueue(new(0, "release installed", ""));
+ }
+ else
+ {
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ }
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = AddDeployment(builder, helm);
+ if (bestEffort)
+ {
+ builder.CreateResourceBuilder(resource)
+ .WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort);
+ }
+ builder.Services.AddSingleton(runner);
+ Exception factoryError = invalidConfig
+ ? new InvalidOperationException("Invalid kubeconfig")
+ : new FileNotFoundException("Missing kubeconfig");
+ builder.Services.AddSingleton>(_ => _ =>
+ throw factoryError);
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+
+ await app.StartAsync(cts.Token);
+ var notifications = app.Services.GetRequiredService();
+ await notifications.WaitForResourceAsync(resource.Name,
+ [KnownResourceStates.Running, KnownResourceStates.FailedToStart], cts.Token);
+ Assert.True(notifications.TryGetCurrentState(resource.Name, out var current));
+ Assert.Equal(bestEffort ? KnownResourceStates.Running : KnownResourceStates.FailedToStart,
+ current.Snapshot.State?.Text);
+
+ if (bestEffort)
+ {
+ var loggerService = app.Services.GetRequiredService();
+ await foreach (var batch in loggerService.WatchAsync(resource.Name).WithCancellation(cts.Token))
+ {
+ if (batch.Any(log => log.Content.Contains("CRD readiness is unverified", StringComparison.Ordinal)
+ && log.Content.Contains(resource.Name)))
+ {
+ return;
+ }
+ }
+ Assert.Fail("Expected an unverified CRD readiness warning.");
+ }
+ }
+
+ [Fact]
+ public async Task BestEffortWarnsThatCrdReadinessIsUnverifiedBeforeRunning()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ var kubernetes = new FakeCrdClient((name, _) => Task.FromResult(
+ FakeCrdClient.Definition(name, established: false)));
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddResource(new KindClusterResource("test-cluster"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Kind Cluster",
+ State = KnownResourceStates.Running,
+ Properties = [],
+ });
+ var resource = cluster.AddManifestFromContent("manifest", "kind: Namespace")
+ .WithCrdWait(options =>
+ {
+ options.FailureBehavior = CrdWaitBehavior.BestEffort;
+ options.Timeout = TimeSpan.FromSeconds(1);
+ }).Resource;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+
+ await app.StartAsync(cts.Token);
+ await app.Services.GetRequiredService()
+ .WaitForResourceAsync(resource.Name, KnownResourceStates.Running, cts.Token);
+
+ var loggerService = app.Services.GetRequiredService();
+ var unverified = false;
+ await foreach (var batch in loggerService.WatchAsync(resource.Name).WithCancellation(cts.Token))
+ {
+ unverified |= batch.Any(log => log.Content.Contains("unverified", StringComparison.OrdinalIgnoreCase)
+ && log.Content.Contains(resource.Name));
+ if (unverified)
+ {
+ break;
+ }
+ }
+ Assert.True(unverified);
+ Assert.True(kubernetes.ReadCount > 0);
+ Assert.Equal(2, runner.Commands.Count);
+ }
+
+ [Fact]
+ public async Task CancelingBestEffortCrdWaitPropagatesCancellation()
+ {
+ var runner = new FakeProcessRunner();
+ runner.Results.Enqueue(new(0, "cluster is running", ""));
+ runner.Results.Enqueue(new(0, "customresourcedefinition.apiextensions.k8s.io/widgets.example.com", ""));
+ TaskCompletionSource enteredWait = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ var kubernetes = new FakeCrdClient(async (name, token) =>
+ {
+ enteredWait.TrySetResult();
+ await Task.Delay(Timeout.InfiniteTimeSpan, token);
+ return FakeCrdClient.Definition(name, established: true);
+ });
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var cluster = builder.AddResource(new KindClusterResource("test-cluster"))
+ .WithInitialState(new CustomResourceSnapshot
+ {
+ ResourceType = "Kind Cluster",
+ State = KnownResourceStates.Running,
+ Properties = [],
+ });
+ var resource = cluster.AddManifestFromContent("manifest", "kind: Namespace")
+ .WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort).Resource;
+ builder.Services.AddSingleton(runner);
+ builder.Services.AddSingleton>(_ => _ => kubernetes.Client);
+ using var app = builder.Build();
+ using var loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(_ => { });
+ await new KubectlManager(runner).ApplyAsync(
+ resource, loggerFactory.CreateLogger("test"), TestContext.Current.CancellationToken);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
+ cts.CancelAfter(TimeSpan.FromSeconds(15));
+ var check = app.Services.GetRequiredService().RunAsync(resource, cts.Token);
+
+ await enteredWait.Task.WaitAsync(cts.Token);
+ cts.Cancel();
+ await Assert.ThrowsAnyAsync(() => check);
+
+ Assert.Equal(
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"],
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames);
+ Assert.Equal(["widgets.example.com"], kubernetes.ReadNames);
+ Assert.Equal(2, runner.Commands.Count);
+ }
+
+ [Fact]
+ public async Task CanceledNoCrdCheckDoesNotSucceed()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("manifest", "kind: Namespace").Resource;
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames = [];
+ using var app = builder.Build();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Assert.ThrowsAnyAsync(() =>
+ app.Services.GetRequiredService().RunAsync(resource, cts.Token));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task CanceledBestEffortReadDoesNotSwallowFailure(bool transportFailure)
+ {
+ using var builder = TestDistributedApplicationBuilder.Create();
+ var resource = builder.AddKindCluster("test-cluster")
+ .AddManifestFromContent("manifest", "kind: CustomResourceDefinition")
+ .WithCrdWait(options => options.FailureBehavior = CrdWaitBehavior.BestEffort).Resource;
+ KindDeploymentOutcomes.GetOrCreate(resource).CrdNames =
+ ["customresourcedefinition.apiextensions.k8s.io/widgets.example.com"];
+ using var cts = new CancellationTokenSource();
+ var kubernetes = new FakeCrdClient((_, _) =>
+ {
+ cts.Cancel();
+ return Task.FromException(transportFailure
+ ? new HttpRequestException("connection failed")
+ : new InvalidOperationException("invalid CRD response"));
+ });
+ builder.Services.AddSingleton