Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,12 @@ public static IResourceBuilder<KubernetesPersistentVolumeResource> WithVolumeAnn
/// <remarks>
/// To bind a workload that does not already have a matching named mount (for
/// example a <c>ProjectResource</c>), use the overload that accepts a
/// <c>mountPath</c> instead.
/// <c>mountPath</c> instead. The generated pod uses an Aspire-managed
/// <c>fsGroup</c> of <c>2000</c> with an <c>OnRootMismatch</c> change policy so
/// non-root containers can access supported volumes without matching the image's
/// primary group. Use
/// <see cref="KubernetesServiceExtensions.PublishAsKubernetesService{T}(IResourceBuilder{T}, Action{KubernetesResource})"/>
/// to customize the pod security context when a different group or policy is required.
/// </remarks>
/// <example>
/// <code>
Expand Down Expand Up @@ -278,6 +283,13 @@ public static IResourceBuilder<T> WithPersistentVolume<T>(
/// <param name="isReadOnly">When <see langword="true"/>, mounts the volume
/// read-only.</param>
/// <returns>The same builder for chaining.</returns>
/// <remarks>
/// The generated pod uses an Aspire-managed <c>fsGroup</c> of <c>2000</c> with
/// an <c>OnRootMismatch</c> change policy so non-root containers can access
/// supported volumes without matching the image's primary group. Use
/// <see cref="KubernetesServiceExtensions.PublishAsKubernetesService{T}(IResourceBuilder{T}, Action{KubernetesResource})"/>
/// to customize the pod security context when a different group or policy is required.
/// </remarks>
/// <example>
/// <code>
/// var media = k8s.AddPersistentVolume("media")
Expand Down
24 changes: 20 additions & 4 deletions src/Aspire.Hosting.Kubernetes/KubernetesResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ namespace Aspire.Hosting.Kubernetes;
[AspireExport(ExposeProperties = true)]
public partial class KubernetesResource(string name, IResource resource, KubernetesEnvironmentResource kubernetesEnvironmentResource) : Resource(name), IResourceWithParent<KubernetesEnvironmentResource>
{
private const long DefaultPersistentVolumeFsGroup = 2000;
private const string DefaultPersistentVolumeFsGroupChangePolicy = "OnRootMismatch";

/// <inheritdoc/>
public KubernetesEnvironmentResource Parent => kubernetesEnvironmentResource;

Expand Down Expand Up @@ -164,18 +167,31 @@ private void SetLabels()

private void CreateApplication()
{
var hasPersistentVolumeBinding = resource.HasAnnotationOfType<KubernetesPersistentVolumeBindingAnnotation>();

// Promote to a StatefulSet when the workload is bound to a first-class persistent
// volume — Kubernetes requires stable identity and ordered rollout for pods that
// share named PVCs. The historical IResourceWithConnectionString rule remains so
// existing integrations that imply state continue to render as StatefulSets.
if (resource is IResourceWithConnectionString ||
resource.HasAnnotationOfType<KubernetesPersistentVolumeBindingAnnotation>())
if (resource is IResourceWithConnectionString || hasPersistentVolumeBinding)
{
Workload = resource.ToStatefulSet(this);
return;
}
else
{
Workload = resource.ToDeployment(this);
}

Workload = resource.ToDeployment(this);
if (hasPersistentVolumeBinding)
{
// fsGroup is a supplemental group, not the image's primary GID. A stable
// publisher-owned value lets non-root images access supported volumes without
// coupling the manifest to image-specific identities. Kubernetes customization
// callbacks run after this default is applied and can replace or remove it.
var securityContext = Workload.PodTemplate.Spec.SecurityContext ??= new();
securityContext.FsGroup ??= DefaultPersistentVolumeFsGroup;
Comment thread
mitchdenny marked this conversation as resolved.
securityContext.FsGroupChangePolicy ??= DefaultPersistentVolumeFsGroupChangePolicy;
Comment thread
mitchdenny marked this conversation as resolved.
}
}

internal string GetContainerImageName(IResource resourceInstance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ await auto.RunCommandAsync(

await WaitForStatefulSetAndManagedDiskAsync(auto, counter);

await VerifyFileSystemGroupAsync(auto, counter, expectedFsGroup: 2000);

await auto.RunCommandAsync(
"PVC_UID_BEFORE=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " +
"POD_UID_BEFORE=$(kubectl get pod apiservice-statefulset-0 --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " +
Expand Down Expand Up @@ -150,6 +152,7 @@ await auto.RunCommandAsync(
"kubectl wait --for=condition=Ready pod/apiservice-statefulset-0 --namespace \"$NS\" --timeout=5m",
counter,
TimeSpan.FromMinutes(6));
await VerifyFileSystemGroupAsync(auto, counter, expectedFsGroup: 3000);
await auto.RunCommandAsync(
"PVC_UID_AFTER=$(kubectl get persistentvolumeclaim data --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " +
"POD_UID_AFTER=$(kubectl get pod apiservice-statefulset-0 --namespace \"$NS\" -o jsonpath='{.metadata.uid}') && " +
Expand All @@ -166,6 +169,12 @@ await VerifyApiResponseAsync(
apiPort,
"?action=read",
"PASSED: read aks-pv-marker-42 revision second");
await VerifyApiResponseAsync(
auto,
counter,
apiPort,
"?action=write-new",
"PASSED: wrote new aks-pv-marker-42 revision second");
await StopPortForwardAsync(auto, counter);

await auto.AspireDestroyAsync(counter);
Expand Down Expand Up @@ -231,17 +240,6 @@ private static void ConfigureAppHost(string appHostPath)
builder.AddProject<Projects.AksPersistentVolume_ApiService>("apiservice")
.WithPersistentVolume(data, "/srv/data")
.WithEnvironment("DEPLOYMENT_REVISION", "first")
.PublishAsKubernetesService(resource =>
{
// .NET containers run as the non-root app user. Apply its group to the
// managed disk so the API can write to the filesystem mounted by kubelet.
var workload = resource.Workload
?? throw new InvalidOperationException("The API Kubernetes workload was not generated.");
workload.PodTemplate.Spec.SecurityContext = new()
{
FsGroup = 1654
};
})
""",
appHostPath);

Expand All @@ -254,7 +252,16 @@ private static void UpdateDeploymentRevision(string appHostPath)
content = ReplaceRequired(
content,
""".WithEnvironment("DEPLOYMENT_REVISION", "first")""",
""".WithEnvironment("DEPLOYMENT_REVISION", "second")""",
"""
.WithEnvironment("DEPLOYMENT_REVISION", "second")
.PublishAsKubernetesService(resource =>
{
var podSpec = resource.Workload?.PodTemplate.Spec
?? throw new InvalidOperationException("The API Kubernetes workload was not generated.");
podSpec.SecurityContext ??= new();
podSpec.SecurityContext.FsGroup = 3000;
})
""",
appHostPath);
File.WriteAllText(appHostPath, content);
}
Expand All @@ -271,6 +278,7 @@ private static void ConfigureApi(string apiProgramPath)
var app = builder.Build();

const string markerPath = "/srv/data/marker.txt";
const string newMarkerPath = "/srv/data/new-marker.txt";
const string markerToken = "aks-pv-marker-42";
var deploymentRevision = app.Configuration["DEPLOYMENT_REVISION"]
?? throw new InvalidOperationException("DEPLOYMENT_REVISION is not configured.");
Expand Down Expand Up @@ -300,7 +308,13 @@ private static void ConfigureApi(string apiProgramPath)
return Results.Ok($"PASSED: read {persistedValue} revision {deploymentRevision}");
}

return Results.BadRequest("FAILED: action must be write or read");
if (action == "write-new")
{
await File.WriteAllTextAsync(newMarkerPath, markerToken);
return Results.Ok($"PASSED: wrote new {markerToken} revision {deploymentRevision}");
}

return Results.BadRequest("FAILED: action must be write, read, or write-new");
});

app.MapDefaultEndpoints();
Expand Down Expand Up @@ -333,6 +347,29 @@ await auto.RunCommandAsync(
TimeSpan.FromMinutes(6));
}

private static async Task VerifyFileSystemGroupAsync(
Hex1bTerminalAutomator auto,
SequenceCounter counter,
long expectedFsGroup)
{
await auto.RunCommandAsync(
$"FS_GROUP=$(kubectl get statefulset apiservice-statefulset --namespace \"$NS\" -o jsonpath='{{.spec.template.spec.securityContext.fsGroup}}') && " +
Comment thread
mitchdenny marked this conversation as resolved.
$"test \"$FS_GROUP\" = \"{expectedFsGroup}\" && " +
// Verify the Linux identity and mount ownership reported as:
// id -u: 1654
// id -G: 1654 2000
// stat -c %g /srv/data: 2000
// This proves the write succeeds through group access rather than root privileges.
"PROCESS_UID=$(kubectl exec pod/apiservice-statefulset-0 --namespace \"$NS\" -- id -u) && " +
"PROCESS_GROUPS=$(kubectl exec pod/apiservice-statefulset-0 --namespace \"$NS\" -- id -G) && " +
"VOLUME_GROUP=$(kubectl exec pod/apiservice-statefulset-0 --namespace \"$NS\" -- stat -c %g /srv/data) && " +
"test \"$PROCESS_UID\" != \"0\" && " +
$"printf ' %s ' \"$PROCESS_GROUPS\" | grep --fixed-strings --quiet ' {expectedFsGroup} ' && " +
$"test \"$VOLUME_GROUP\" = \"{expectedFsGroup}\" && " +
$"echo \"StatefulSet uses fsGroup {expectedFsGroup}; pod UID is $PROCESS_UID with groups $PROCESS_GROUPS; /srv/data group is $VOLUME_GROUP\"",
counter);
}

private static async Task DeployAsync(
Hex1bTerminalAutomator auto,
SequenceCounter counter,
Expand Down
69 changes: 69 additions & 0 deletions tests/Aspire.Hosting.Kubernetes.Tests/KubernetesPublisherTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1289,6 +1289,71 @@ public async Task PublishAsync_WithFirstClassPersistentVolume_OnProject_BindsVia
await settingsTask;
}

[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_KubernetesCustomizationOverridesDefaultFsGroup()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data");

builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(data, "/srv/data")
.PublishAsKubernetesService(resource =>
{
var podSpec = resource.Workload?.PodTemplate.Spec
?? throw new InvalidOperationException("The Kubernetes workload was not generated.");
podSpec.SecurityContext ??= new();
podSpec.SecurityContext.FsGroup = 3000;
});

var app = builder.Build();
app.Run();

var statefulSetPath = Path.Combine(workspace.Path, "templates", "api", "statefulset.yaml");
var content = await File.ReadAllTextAsync(statefulSetPath);

await Verify(content, "yaml");
}

[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_KubernetesCustomizationCanRemoveDefaultSecurityContext()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, workspace.Path);

var k8s = builder.AddKubernetesEnvironment("env");
var data = k8s.AddPersistentVolume("data");

builder.AddProject<TestProject>("api", launchProfileName: null)
.WithPersistentVolume(data, "/srv/data")
.PublishAsKubernetesService(resource =>
{
var podSpec = resource.Workload?.PodTemplate.Spec
?? throw new InvalidOperationException("The Kubernetes workload was not generated.");
podSpec.SecurityContext = null;
});

var app = builder.Build();
app.Run();

var statefulSetPath = Path.Combine(workspace.Path, "templates", "api", "statefulset.yaml");
var content = await File.ReadAllTextAsync(statefulSetPath);

var yaml = new YamlStream();
using (var reader = new StringReader(content))
{
yaml.Load(reader);
}

var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var podSpec = (YamlMappingNode)root["spec"]["template"]["spec"];
Assert.False(podSpec.Children.ContainsKey(new YamlScalarNode("securityContext")));

await Verify(content, "yaml");
}

[Fact]
public async Task PublishAsync_WithFirstClassPersistentVolume_FallsThroughForUnboundVolumes()
{
Expand Down Expand Up @@ -1400,6 +1465,10 @@ public async Task PublishAsync_WithFirstClassPersistentVolume_ReadOnlyFlag_Propa
var root = (YamlMappingNode)yaml.Documents[0].RootNode;
var podSpec = (YamlMappingNode)root["spec"]["template"]["spec"];

var securityContext = (YamlMappingNode)podSpec["securityContext"];
Assert.Equal("2000", ((YamlScalarNode)securityContext["fsGroup"]).Value);
Assert.Equal("OnRootMismatch", ((YamlScalarNode)securityContext["fsGroupChangePolicy"]).Value);

// Container mount side: containers[0].volumeMounts[?(@.name == "media")].readOnly == true
var container = (YamlMappingNode)((YamlSequenceNode)podSpec["containers"])[0];
var volumeMount = ((YamlSequenceNode)container["volumeMounts"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ spec:
- name: "data"
persistentVolumeClaim:
claimName: "data"
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"
selector:
matchLabels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ spec:
claimName: "data"
- name: "scratch"
emptyDir: {}
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"
selector:
matchLabels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
apiVersion: "apps/v1"
kind: "StatefulSet"
metadata:
name: "api-statefulset"
labels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
spec:
template:
metadata:
labels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
spec:
containers:
- image: "{{ .Values.parameters.api.api_image }}"
name: "api"
envFrom:
- configMapRef:
name: "api-config"
volumeMounts:
- name: "data"
mountPath: "/srv/data"
imagePullPolicy: "IfNotPresent"
volumes:
- name: "data"
persistentVolumeClaim:
claimName: "data"
selector:
matchLabels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: 1
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
---
apiVersion: "apps/v1"
kind: "StatefulSet"
metadata:
name: "api-statefulset"
labels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
spec:
template:
metadata:
labels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
spec:
containers:
- image: "{{ .Values.parameters.api.api_image }}"
name: "api"
envFrom:
- configMapRef:
name: "api-config"
volumeMounts:
- name: "data"
mountPath: "/srv/data"
imagePullPolicy: "IfNotPresent"
volumes:
- name: "data"
persistentVolumeClaim:
claimName: "data"
securityContext:
fsGroup: 3000
fsGroupChangePolicy: "OnRootMismatch"
selector:
matchLabels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
app.kubernetes.io/component: "api"
app.kubernetes.io/instance: "{{ .Release.Name }}"
replicas: 1
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ spec:
- name: "media"
persistentVolumeClaim:
claimName: "media"
securityContext:
fsGroup: 2000
fsGroupChangePolicy: "OnRootMismatch"
selector:
matchLabels:
app.kubernetes.io/name: "{{ .Chart.Name }}"
Expand Down
Loading