Skip to content

Commit dc35f2b

Browse files
authored
Fix DCP monitor timestamp serialization (#18798)
* Fix DCP monitor timestamp serialization Serialize parent-process monitor timestamps using Kubernetes MicroTime's fixed six-digit fractional second format so DCP can accept parent-scoped container and executable resources. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62834f08-7833-42e2-af31-f71213611735 * Account for MicroTime precision in DCP test Update the parent-process lifetime model test to compare monitor timestamps at Kubernetes MicroTime precision after the fake Kubernetes service JSON round-trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62834f08-7833-42e2-af31-f71213611735 * Use timestamp tolerance in DCP test Replace the custom MicroTime precision helper with xUnit's DateTime precision assertion using a one microsecond tolerance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 62834f08-7833-42e2-af31-f71213611735
1 parent 7a1fb60 commit dc35f2b

4 files changed

Lines changed: 104 additions & 2 deletions

File tree

src/Aspire.Hosting/Dcp/Model/Container.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ internal sealed class ContainerSpec
6969

7070
// Optional parent process identity timestamp used with MonitorPid to guard against PID reuse.
7171
[JsonPropertyName("monitorTimestamp")]
72+
[JsonConverter(typeof(KubernetesMicroTimeJsonConverter))]
7273
public DateTime? MonitorTimestamp { get; set; }
7374

7475
[JsonPropertyName("networks")]

src/Aspire.Hosting/Dcp/Model/Executable.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ internal sealed class ExecutableSpec
7575
/// Optional parent process identity timestamp used with <see cref="MonitorPid"/> to guard against PID reuse.
7676
/// </summary>
7777
[JsonPropertyName("monitorTimestamp")]
78+
[JsonConverter(typeof(KubernetesMicroTimeJsonConverter))]
7879
public DateTime? MonitorTimestamp { get; set; }
7980

8081
/// <summary>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Globalization;
5+
using System.Text.Json;
6+
using System.Text.Json.Serialization;
7+
8+
namespace Aspire.Hosting.Dcp.Model;
9+
10+
internal sealed class KubernetesMicroTimeJsonConverter : JsonConverter<DateTime?>
11+
{
12+
private const string UtcFormat = "yyyy-MM-dd'T'HH:mm:ss.ffffff'Z'";
13+
private const string OffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.ffffffzzz";
14+
15+
public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
16+
{
17+
if (reader.TokenType is JsonTokenType.Null)
18+
{
19+
return null;
20+
}
21+
22+
if (reader.TokenType is not JsonTokenType.String)
23+
{
24+
throw new JsonException($"Expected a string token for Kubernetes MicroTime but found {reader.TokenType}.");
25+
}
26+
27+
var value = reader.GetString();
28+
if (string.IsNullOrEmpty(value))
29+
{
30+
throw new JsonException("Expected a non-empty Kubernetes MicroTime value.");
31+
}
32+
33+
return value.EndsWith('Z')
34+
? DateTime.ParseExact(value, UtcFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal)
35+
: DateTimeOffset.ParseExact(value, OffsetFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal).UtcDateTime;
36+
}
37+
38+
public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options)
39+
{
40+
if (value is null)
41+
{
42+
writer.WriteNullValue();
43+
return;
44+
}
45+
46+
// DCP models these fields as Kubernetes metav1.MicroTime, whose JSON shape is fixed-width:
47+
// "2026-07-15T18:46:06.123000Z"
48+
// See https://github.com/kubernetes/apimachinery/blob/v0.36.0/pkg/apis/meta/v1/micro_time.go.
49+
// System.Text.Json trims fractional seconds for DateTime by default, which DCP rejects when
50+
// the value is submitted to the API server.
51+
var timestamp = value.Value.Kind switch
52+
{
53+
DateTimeKind.Utc => value.Value,
54+
DateTimeKind.Local => value.Value.ToUniversalTime(),
55+
_ => DateTime.SpecifyKind(value.Value, DateTimeKind.Utc)
56+
};
57+
58+
writer.WriteStringValue(timestamp.ToString(UtcFormat, CultureInfo.InvariantCulture));
59+
}
60+
}

tests/Aspire.Hosting.Tests/Dcp/DcpExecutorTests.cs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3402,7 +3402,8 @@ public async Task ExplicitParentProcessLifetimeIncludesMonitorProcess()
34023402
var container = Assert.Single(kubernetesService.CreatedResources.OfType<Container>());
34033403
Assert.True(container.Spec.Persistent.GetValueOrDefault());
34043404
Assert.Equal(parentProcessIdentity.ProcessId, container.Spec.MonitorPid);
3405-
Assert.Equal(parentProcessIdentity.Timestamp, container.Spec.MonitorTimestamp);
3405+
Assert.NotNull(container.Spec.MonitorTimestamp);
3406+
Assert.Equal(parentProcessIdentity.Timestamp, container.Spec.MonitorTimestamp.Value, TimeSpan.FromMicroseconds(1));
34063407

34073408
var executables = kubernetesService.CreatedResources.OfType<Executable>()
34083409
.Where(e => e.AppModelResourceName is "worker" or "project")
@@ -3412,7 +3413,8 @@ public async Task ExplicitParentProcessLifetimeIncludesMonitorProcess()
34123413
{
34133414
Assert.True(exe.Spec.Persistent.GetValueOrDefault());
34143415
Assert.Equal(parentProcessIdentity.ProcessId, exe.Spec.MonitorPid);
3415-
Assert.Equal(parentProcessIdentity.Timestamp, exe.Spec.MonitorTimestamp);
3416+
Assert.NotNull(exe.Spec.MonitorTimestamp);
3417+
Assert.Equal(parentProcessIdentity.Timestamp, exe.Spec.MonitorTimestamp.Value, TimeSpan.FromMicroseconds(1));
34163418
Assert.Equal(ExecutionType.Process, exe.Spec.ExecutionType);
34173419
});
34183420
}
@@ -6119,6 +6121,44 @@ public void TerminalSpec_SerializesToDcpWireContract()
61196121
json);
61206122
}
61216123

6124+
[Fact]
6125+
public void MonitorTimestamps_SerializeToDcpMicroTimeWireContract()
6126+
{
6127+
var wholeSecondTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30), DateTimeKind.Utc);
6128+
var fractionalSecondTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30).AddMilliseconds(123), DateTimeKind.Utc);
6129+
6130+
AssertMonitorTimestamp(new ContainerSpec { MonitorPid = 1234, MonitorTimestamp = wholeSecondTimestamp }, "0001-01-01T00:06:30.000000Z");
6131+
AssertMonitorTimestamp(new ContainerSpec { MonitorPid = 1234, MonitorTimestamp = fractionalSecondTimestamp }, "0001-01-01T00:06:30.123000Z");
6132+
AssertMonitorTimestamp(new ExecutableSpec { MonitorPid = 1234, MonitorTimestamp = wholeSecondTimestamp }, "0001-01-01T00:06:30.000000Z");
6133+
AssertMonitorTimestamp(new ExecutableSpec { MonitorPid = 1234, MonitorTimestamp = fractionalSecondTimestamp }, "0001-01-01T00:06:30.123000Z");
6134+
6135+
static void AssertMonitorTimestamp<T>(T spec, string expected)
6136+
{
6137+
// DCP models monitorTimestamp as Kubernetes metav1.MicroTime:
6138+
// "0001-01-01T00:06:30.000000Z"
6139+
// Kubernetes requires exactly six fractional digits, while System.Text.Json's
6140+
// default DateTime converter trims trailing zeroes and can produce values
6141+
// such as "0001-01-01T00:06:30Z" that DCP rejects.
6142+
var json = JsonSerializer.Serialize(spec);
6143+
using var document = JsonDocument.Parse(json);
6144+
6145+
Assert.Equal(expected, document.RootElement.GetProperty("monitorTimestamp").GetString());
6146+
}
6147+
}
6148+
6149+
[Fact]
6150+
public void MonitorTimestamps_DeserializeFromDcpMicroTimeWireContract()
6151+
{
6152+
var containerSpec = JsonSerializer.Deserialize<ContainerSpec>("""{"monitorPid":1234,"monitorTimestamp":"0001-01-01T00:06:30.123000Z"}""");
6153+
var executableSpec = JsonSerializer.Deserialize<ExecutableSpec>("""{"monitorPid":1234,"monitorTimestamp":"0001-01-01T00:06:30.123000Z"}""");
6154+
var expectedTimestamp = DateTime.SpecifyKind(DateTime.MinValue.AddMinutes(6).AddSeconds(30).AddMilliseconds(123), DateTimeKind.Utc);
6155+
6156+
Assert.NotNull(containerSpec);
6157+
Assert.Equal(expectedTimestamp, containerSpec.MonitorTimestamp);
6158+
Assert.NotNull(executableSpec);
6159+
Assert.Equal(expectedTimestamp, executableSpec.MonitorTimestamp);
6160+
}
6161+
61226162
private static DcpExecutor CreateAppExecutor(
61236163
DistributedApplicationModel distributedAppModel,
61246164
IHostEnvironment? hostEnvironment = null,

0 commit comments

Comments
 (0)