Skip to content

Commit 19d3265

Browse files
authored
feat(hosting): add better k3s support (#1173)
- Introduced generic `RunWithKubernetes<TResource>` overload for resources exposing kubeconfig as connection string. - Enhanced CRD management to work seamlessly with non-`KubernetesEnvironmentResource` clusters. - Updated tests and documentation to reflect new functionality. fixes #1170
1 parent 939ef9d commit 19d3265

6 files changed

Lines changed: 164 additions & 15 deletions

File tree

docs/docs/operator/aspire-kubernetes-model.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,16 +223,17 @@ builder.AddKubeOps<Projects.AspireOperator>("operator")
223223

224224
KubeOps runtime watchers require a Kubernetes API server. Unit tests can replace `IKubernetesClient`, but the normal operator runtime is not an offline simulator.
225225

226-
Additional targets can be modeled as Kubernetes environments if they provide Kubernetes API semantics:
226+
Cluster resources that are not `KubernetesEnvironmentResource`s but expose their kubeconfig path as a connection string — such as the `K3sClusterResource` from the [CommunityToolkit Aspire k3s integration](https://github.com/CommunityToolkit/Aspire) — are supported by the generic `RunWithKubernetes<TResource>` overload (constrained to `IResourceWithConnectionString`):
227227

228228
```csharp
229229
var k3s = builder.AddK3sCluster("k3s");
230-
var mock = builder.AddKubeOpsMockKubernetes("mock-kube");
231230

232231
builder.AddKubeOps<Projects.AspireOperator>("operator")
233232
.RunWithKubernetes(k3s);
234233
```
235234

235+
This overload injects the cluster's `KUBECONFIG` into the operator process (`WithReference`), waits for the cluster (`WaitFor`), starts the operator automatically, and manages CRDs through the same lifecycle as the environment overload — no `KubernetesEnvironmentResource` inheritance required. Publishing still targets a `KubernetesEnvironmentResource`.
236+
236237
A mock target would require operator-side runtime support to replace the Kubernetes client and watcher behavior. It should not be represented as a normal Kubernetes environment unless it provides Kubernetes API semantics.
237238

238239
## Non-Goals

docs/docs/operator/aspire.mdx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ builder.AddKubeOps<Projects.AspireOperator>("operator")
209209
.RunWithKubernetes(dev, run => run.WithPersistentCrds());
210210
```
211211

212+
Local development against a k3s cluster from the [CommunityToolkit Aspire k3s integration](https://github.com/CommunityToolkit/Aspire) (or any resource whose connection string is a kubeconfig path):
213+
214+
```csharp
215+
var k3s = builder.AddK3sCluster("k3s");
216+
217+
builder.AddKubeOps<Projects.AspireOperator>("operator")
218+
.RunWithKubernetes(k3s, run => run.WithPersistentCrds());
219+
```
220+
221+
The generic `RunWithKubernetes<TResource>` overload accepts any `IResourceWithConnectionString` whose connection string is a kubeconfig path. It injects the cluster's `KUBECONFIG` into the operator process, waits for the cluster to become ready, starts the operator automatically, and manages CRDs exactly like the `KubernetesEnvironmentResource` overload. Publishing (`PublishAsKubernetesOperator`) still requires a `KubernetesEnvironmentResource`.
222+
212223
Azure deployment into AKS:
213224

214225
```csharp

src/KubeOps.Aspire.Hosting/KubeOpsHostingExtensions.cs

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,53 @@ public static IResourceBuilder<ProjectResource> RunWithKubernetes(
159159
return builder;
160160
}
161161

162+
/// <summary>
163+
/// Configures a KubeOps operator project to run locally against a cluster resource that exposes its kubeconfig
164+
/// path as a connection string.
165+
/// </summary>
166+
/// <remarks>
167+
/// This overload targets cluster resources that are not <see cref="KubernetesEnvironmentResource"/>s but expose a
168+
/// kubeconfig path through <see cref="IResourceWithConnectionString"/> — for example a
169+
/// <c>K3sClusterResource</c> from the CommunityToolkit Aspire k3s integration, whose connection string is the
170+
/// host-accessible kubeconfig path. The operator process receives the cluster's <c>KUBECONFIG</c> (via
171+
/// <c>WithReference</c>), waits for the cluster to become ready (via <c>WaitFor</c>), starts automatically, and has
172+
/// its CRDs managed according to the configured <see cref="KubeOpsRunCrdMode"/>.
173+
/// </remarks>
174+
/// <typeparam name="TResource">The cluster resource type exposing a kubeconfig path as its connection string.</typeparam>
175+
/// <param name="builder">The operator project resource builder.</param>
176+
/// <param name="target">The cluster resource used by the local operator process.</param>
177+
/// <param name="configure">Configures local run behavior.</param>
178+
/// <returns>The same project builder after local run configuration is attached.</returns>
179+
public static IResourceBuilder<ProjectResource> RunWithKubernetes<TResource>(
180+
this IResourceBuilder<ProjectResource> builder,
181+
IResourceBuilder<TResource> target,
182+
Action<KubeOpsRunOptions>? configure = null)
183+
where TResource : class, IResource, IResourceWithConnectionString
184+
{
185+
ArgumentNullException.ThrowIfNull(builder);
186+
ArgumentNullException.ThrowIfNull(target);
187+
188+
RemoveExplicitStart(builder.Resource);
189+
190+
var publishAnnotation = builder.Resource.Annotations.OfType<KubeOpsPublishAnnotation>().SingleOrDefault()
191+
?? throw new InvalidOperationException(
192+
$"{nameof(RunWithKubernetes)} can only be used with project resources added via " +
193+
$"{nameof(AddKubeOps)}.");
194+
195+
// Inject the cluster's KUBECONFIG into the operator process and defer start/CRD prep until it is ready.
196+
builder.WithReference(target);
197+
builder.WaitFor(target);
198+
199+
var options = new KubeOpsRunOptions(
200+
target.Resource,
201+
cancellationToken => target.Resource.GetConnectionStringAsync(cancellationToken));
202+
configure?.Invoke(options);
203+
var runAnnotation = new KubeOpsRunAnnotation(options);
204+
builder.Resource.Annotations.Add(runAnnotation);
205+
ConfigureKubeOpsRun(builder, publishAnnotation, runAnnotation);
206+
return builder;
207+
}
208+
162209
private static KubeOpsKubernetesManifestOptions CreateDefaultManifestOptions(string name)
163210
=> new()
164211
{
@@ -243,6 +290,8 @@ private static async Task PrepareRunCrdsAsync(
243290
return;
244291
}
245292

293+
var kubeConfigPath = await run.Options.ResolveKubeConfigPathAsync(cancellationToken);
294+
246295
foreach (var crd in GenerateKubeOpsJsonResources(publish.ProjectPath, publish.Options)
247296
.Where(resource => IsKind(resource, "CustomResourceDefinition")))
248297
{
@@ -252,7 +301,7 @@ private static async Task PrepareRunCrdsAsync(
252301
continue;
253302
}
254303

255-
var exists = await KubernetesResourceExistsAsync(run.Options.Target, "crd", name, cancellationToken);
304+
var exists = await KubernetesResourceExistsAsync(kubeConfigPath, "crd", name, cancellationToken);
256305
if (run.Options.CrdMode is KubeOpsRunCrdMode.RequireExisting)
257306
{
258307
if (!exists)
@@ -267,7 +316,7 @@ private static async Task PrepareRunCrdsAsync(
267316

268317
if (!exists || run.Options.CrdMode is KubeOpsRunCrdMode.Persistent)
269318
{
270-
await ApplyKubernetesJsonAsync(run.Options.Target, crd, cancellationToken);
319+
await ApplyKubernetesJsonAsync(kubeConfigPath, crd, cancellationToken);
271320
}
272321

273322
if (!exists && run.Options.CrdMode is KubeOpsRunCrdMode.Ephemeral)
@@ -286,24 +335,26 @@ private static async Task CleanupRunCrdsAsync(
286335
return;
287336
}
288337

338+
var kubeConfigPath = await run.Options.ResolveKubeConfigPathAsync(cancellationToken);
339+
289340
foreach (var crd in run.CreatedCrds)
290341
{
291342
await RunKubectlAsync(
292-
run.Options.Target,
343+
kubeConfigPath,
293344
["delete", "crd", crd, "--ignore-not-found"],
294345
cancellationToken,
295346
throwOnError: false);
296347
}
297348
}
298349

299350
private static async Task<bool> KubernetesResourceExistsAsync(
300-
KubernetesEnvironmentResource target,
351+
string? kubeConfigPath,
301352
string kind,
302353
string name,
303354
CancellationToken cancellationToken)
304355
{
305356
var exitCode = await RunKubectlAsync(
306-
target,
357+
kubeConfigPath,
307358
["get", kind, name, "-o", "name"],
308359
cancellationToken,
309360
throwOnError: false);
@@ -312,7 +363,7 @@ private static async Task<bool> KubernetesResourceExistsAsync(
312363
}
313364

314365
private static async Task ApplyKubernetesJsonAsync(
315-
KubernetesEnvironmentResource target,
366+
string? kubeConfigPath,
316367
JsonObject resource,
317368
CancellationToken cancellationToken)
318369
{
@@ -322,7 +373,7 @@ private static async Task ApplyKubernetesJsonAsync(
322373

323374
try
324375
{
325-
await RunKubectlAsync(target, ["apply", "-f", file], cancellationToken);
376+
await RunKubectlAsync(kubeConfigPath, ["apply", "-f", file], cancellationToken);
326377
}
327378
finally
328379
{
@@ -335,7 +386,7 @@ private static async Task ApplyKubernetesJsonAsync(
335386
"S4036:Searching OS commands in PATH is security-sensitive",
336387
Justification = "kubectl is intentionally resolved from PATH; its location is environment-specific and cannot be hardcoded.")]
337388
private static async Task<int> RunKubectlAsync(
338-
KubernetesEnvironmentResource target,
389+
string? kubeConfigPath,
339390
IReadOnlyList<string> arguments,
340391
CancellationToken cancellationToken,
341392
bool throwOnError = true)
@@ -348,10 +399,10 @@ private static async Task<int> RunKubectlAsync(
348399
UseShellExecute = false,
349400
};
350401

351-
if (!string.IsNullOrWhiteSpace(target.KubeConfigPath))
402+
if (!string.IsNullOrWhiteSpace(kubeConfigPath))
352403
{
353404
startInfo.ArgumentList.Add("--kubeconfig");
354-
startInfo.ArgumentList.Add(target.KubeConfigPath);
405+
startInfo.ArgumentList.Add(kubeConfigPath);
355406
}
356407

357408
foreach (var argument in arguments)

src/KubeOps.Aspire.Hosting/KubeOpsRunOptions.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
33
// See the LICENSE file in the project root for more information.
44

5+
using Aspire.Hosting.ApplicationModel;
56
using Aspire.Hosting.Kubernetes;
67

78
namespace Aspire.Hosting;
@@ -12,20 +13,40 @@ namespace Aspire.Hosting;
1213
public sealed class KubeOpsRunOptions
1314
{
1415
internal KubeOpsRunOptions(KubernetesEnvironmentResource target)
16+
: this(target, _ => new ValueTask<string?>(target.KubeConfigPath))
1517
{
16-
Target = target;
18+
}
19+
20+
internal KubeOpsRunOptions(
21+
IResource targetResource,
22+
Func<CancellationToken, ValueTask<string?>> resolveKubeConfigPathAsync)
23+
{
24+
TargetResource = targetResource;
25+
Target = targetResource as KubernetesEnvironmentResource;
26+
ResolveKubeConfigPathAsync = resolveKubeConfigPathAsync;
1727
}
1828

1929
/// <summary>
20-
/// Gets the Kubernetes environment used by the local operator process.
30+
/// Gets the Kubernetes environment used by the local operator process, or <see langword="null"/> when the target
31+
/// is a connection-string resource (e.g. a k3s cluster) rather than a <see cref="KubernetesEnvironmentResource"/>.
2132
/// </summary>
22-
public KubernetesEnvironmentResource Target { get; }
33+
public KubernetesEnvironmentResource? Target { get; }
34+
35+
/// <summary>
36+
/// Gets the underlying target resource used by the local operator process.
37+
/// </summary>
38+
public IResource TargetResource { get; }
2339

2440
/// <summary>
2541
/// Gets the CRD lifecycle mode used for local run.
2642
/// </summary>
2743
public KubeOpsRunCrdMode CrdMode { get; private set; } = KubeOpsRunCrdMode.Ephemeral;
2844

45+
/// <summary>
46+
/// Resolves the kubeconfig path used to communicate with the target cluster.
47+
/// </summary>
48+
internal Func<CancellationToken, ValueTask<string?>> ResolveKubeConfigPathAsync { get; }
49+
2950
/// <summary>
3051
/// Creates missing CRDs before run and removes only CRDs created by this run on shutdown.
3152
/// </summary>

src/KubeOps.Aspire.Hosting/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,15 @@ builder.AddKubeOps<Projects.AspireOperator>("operator")
4242
.RunWithKubernetes(dev, run => run.WithPersistentCrds());
4343
```
4444

45+
`RunWithKubernetes` also has a generic overload that accepts any `IResourceWithConnectionString` whose connection string is a kubeconfig path — for example a `K3sClusterResource` from the CommunityToolkit Aspire k3s integration. It injects the cluster's `KUBECONFIG` into the operator process, waits for the cluster, starts the operator automatically, and manages CRDs identically:
46+
47+
```csharp
48+
var k3s = builder.AddK3sCluster("k3s");
49+
50+
builder.AddKubeOps<Projects.AspireOperator>("operator")
51+
.RunWithKubernetes(k3s);
52+
```
53+
4554
When the AppHost uses Aspire's Kubernetes publishing support, `PublishAsKubernetesOperator(k8s)` invokes `kubeops generate operator` and appends the generated CRDs, RBAC resources, and service account to the Aspire-generated Helm chart. The operator deployment itself remains Aspire-owned, but KubeOps' generated deployment settings are merged into that workload so the chart deploys one correctly wired operator deployment.
4655

4756
```csharp

test/KubeOps.Aspire.Hosting.Test/KubeOpsHosting.Test.cs

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,52 @@ public void RunWithKubernetes_Can_Use_Persistent_Crds()
8686
.Which.Options.CrdMode.Should().Be(KubeOpsRunCrdMode.Persistent);
8787
}
8888

89+
[Fact]
90+
public void RunWithKubernetes_With_ConnectionString_Resource_Enables_Local_Run_With_Ephemeral_Crds_By_Default()
91+
{
92+
var builder = CreateBuilder();
93+
var cluster = builder.AddResource(new FakeClusterResource("fake-k3s"));
94+
95+
var resourceBuilder = builder.AddKubeOps<TestProjectMetadata>("operator-test")
96+
.RunWithKubernetes(cluster);
97+
98+
resourceBuilder.Resource.Annotations
99+
.Should().NotContain(annotation => annotation is ExplicitStartupAnnotation);
100+
var runOptions = resourceBuilder.Resource.Annotations.OfType<KubeOpsRunAnnotation>()
101+
.Should().ContainSingle().Which.Options;
102+
runOptions.CrdMode.Should().Be(KubeOpsRunCrdMode.Ephemeral);
103+
runOptions.Target.Should().BeNull();
104+
runOptions.TargetResource.Should().BeSameAs(cluster.Resource);
105+
}
106+
107+
[Fact]
108+
public void RunWithKubernetes_With_ConnectionString_Resource_Adds_Reference_And_WaitFor()
109+
{
110+
var builder = CreateBuilder();
111+
var cluster = builder.AddResource(new FakeClusterResource("fake-k3s"));
112+
113+
var resourceBuilder = builder.AddKubeOps<TestProjectMetadata>("operator-test")
114+
.RunWithKubernetes(cluster);
115+
116+
resourceBuilder.Resource.Annotations
117+
.Should().Contain(annotation => annotation is EnvironmentCallbackAnnotation)
118+
.And.Contain(annotation => annotation is WaitAnnotation);
119+
}
120+
121+
[Fact]
122+
public void RunWithKubernetes_With_ConnectionString_Resource_Can_Use_Persistent_Crds()
123+
{
124+
var builder = CreateBuilder();
125+
var cluster = builder.AddResource(new FakeClusterResource("fake-k3s"));
126+
127+
var resourceBuilder = builder.AddKubeOps<TestProjectMetadata>("operator-test")
128+
.RunWithKubernetes(cluster, run => run.WithPersistentCrds());
129+
130+
resourceBuilder.Resource.Annotations.OfType<KubeOpsRunAnnotation>()
131+
.Should().ContainSingle()
132+
.Which.Options.CrdMode.Should().Be(KubeOpsRunCrdMode.Persistent);
133+
}
134+
89135
[Fact]
90136
public void PublishAsKubernetesOperator_Configures_Service_Account()
91137
{
@@ -134,4 +180,14 @@ private sealed class TestProjectMetadata : IProjectMetadata
134180
{
135181
public string ProjectPath => typeof(TestProjectMetadata).Assembly.Location;
136182
}
183+
184+
// Mimics CommunityToolkit's K3sClusterResource: a non-KubernetesEnvironmentResource cluster that exposes its
185+
// kubeconfig path as a connection string injected as KUBECONFIG.
186+
private sealed class FakeClusterResource(string name) : Resource(name), IResourceWithConnectionString
187+
{
188+
public string ConnectionStringEnvironmentVariable => "KUBECONFIG";
189+
190+
public ReferenceExpression ConnectionStringExpression =>
191+
ReferenceExpression.Create($"/tmp/{Name}/kubeconfig.yaml");
192+
}
137193
}

0 commit comments

Comments
 (0)