diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdConstants.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdConstants.cs new file mode 100644 index 00000000000000..b85916996430e4 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdConstants.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Hosting.Systemd +{ + internal static class SystemdConstants + { + /// + /// Environment variable set by systemd (v229+) to the path of the notify socket for the service. If this variable is set, the service should send status notifications to systemd using this socket. + /// + /// + internal const string NotifySocket = "NOTIFY_SOCKET"; + + /// + /// Environment variable set by systemd (v248+) to the PID of the main service process. + /// This is the most reliable way to detect if we're running as a systemd service, as it doesn't + /// require reading /proc and works even when ProtectProc=invisible is set. + /// + /// + internal const string SystemdExecPid = "SYSTEMD_EXEC_PID"; + + /// + /// Environment variable set by systemd for socket activation, indicating the PID + /// that should receive the listen file descriptors. + /// + /// + internal const string ListenPid = "LISTEN_PID"; + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHelpers.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHelpers.cs index 3b8259bd799234..b88670315ad7a2 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHelpers.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHelpers.cs @@ -31,17 +31,33 @@ private static bool GetIsSystemdService() return false; } - // To support containerized systemd services, check if we're the main process (PID 1) - // and if there are systemd environment variables defined for notifying the service - // manager, or passing listen handles. int processId = Environment.ProcessId; + // Preferred detection method: compare SYSTEMD_EXEC_PID to the current PID. + // Works even when ProtectProc=invisible hides /proc entries. + string? systemdExecPid = Environment.GetEnvironmentVariable(SystemdConstants.SystemdExecPid); + if (!string.IsNullOrEmpty(systemdExecPid)) + { + if (int.TryParse(systemdExecPid, NumberStyles.None, CultureInfo.InvariantCulture, out int execPid)) + { + if (execPid == processId) + { + return true; + } + // Mismatch: fall through to PID 1 / legacy checks + } + // Malformed value: don't trust it, fall through to legacy detection. + } + + // To support containerized systemd services (e.g. Podman with --sdnotify=container) if (processId == 1) { - return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("NOTIFY_SOCKET")) || - !string.IsNullOrEmpty(Environment.GetEnvironmentVariable("LISTEN_PID")); + return !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket)) || + !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(SystemdConstants.ListenPid)); } + // Legacy detection for systemd < 248 (e.g. Ubuntu 20.04, Debian 11). + // Note: silently returns false if /proc cannot be read, such as when ProtectProc=invisible is set. try { // Check whether our direct parent is 'systemd'. @@ -56,5 +72,36 @@ private static bool GetIsSystemdService() return false; } + + private static readonly bool _isSystemdNotify = GetIsSystemdNotify(); + + /// + /// Checks if the current process has systemd notify enabled. + /// + private static bool IsSystemdNotify() => _isSystemdNotify; + + private static bool GetIsSystemdNotify() + { + // No point in testing anything unless it's Unix + if (Environment.OSVersion.Platform != PlatformID.Unix) + { + return false; + } + // Checks whether NOTIFY_SOCKET is set, indicating the service manager expects sd_notify notifications. + string? socketPath = Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket); + return !string.IsNullOrEmpty(socketPath); + } + + /// + /// Checks if the systemd journal log formatter should be enabled. + /// + // TODO: #127218 + internal static bool IsSystemdLogger() => IsSystemdService(); + + /// + /// Checks if and should be registered. + /// + /// is a noop when NOTIFY_SOCKET is absent. + internal static bool IsSystemdLifetime() => IsSystemdService() || IsSystemdNotify(); } } diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHostBuilderExtensions.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHostBuilderExtensions.cs index 814160886b3c63..6e4c43416b32d6 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHostBuilderExtensions.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdHostBuilderExtensions.cs @@ -16,16 +16,21 @@ public static class SystemdHostBuilderExtensions /// /// Configures the lifetime to , /// provides notification messages for application started and stopping, - /// and configures console logging to the systemd format. + /// and configures console logging to the systemd format when running as a systemd service. /// /// /// /// This is context aware and will only activate if it detects the process is running - /// as a systemd Service. + /// as a systemd Service or if NOTIFY_SOCKET is set. + /// + /// + /// The console log formatter is enabled when the process is detected as a systemd service. + /// The and are registered when + /// NOTIFY_SOCKET is set or the process is detected as a systemd service. /// /// /// The systemd service file must be configured with Type=notify to enable - /// notifications. See https://www.freedesktop.org/software/systemd/man/systemd.service.html. + /// notifications. See . /// /// /// The to configure. @@ -34,7 +39,15 @@ public static IHostBuilder UseSystemd(this IHostBuilder hostBuilder) { ArgumentNullException.ThrowIfNull(hostBuilder); - if (SystemdHelpers.IsSystemdService()) + if (SystemdHelpers.IsSystemdLogger()) + { + hostBuilder.ConfigureServices((hostContext, services) => + { + AddSystemdLogger(services); + }); + } + + if (SystemdHelpers.IsSystemdLifetime()) { hostBuilder.ConfigureServices((hostContext, services) => { @@ -47,12 +60,17 @@ public static IHostBuilder UseSystemd(this IHostBuilder hostBuilder) /// /// Configures the lifetime of the built from to /// , provides notification messages for application started - /// and stopping, and configures console logging to the systemd format. + /// and stopping, and configures console logging to the systemd format when running as a systemd service. /// /// /// /// This is context aware and will only activate if it detects the process is running - /// as a systemd Service. + /// as a systemd Service or if NOTIFY_SOCKET is set. + /// + /// + /// The console log formatter is enabled when the process is detected as a systemd service. + /// The and are registered when + /// NOTIFY_SOCKET is set or the process is detected as a systemd service. /// /// /// The systemd service file must be configured with Type=notify to enable @@ -69,21 +87,33 @@ public static IServiceCollection AddSystemd(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - if (SystemdHelpers.IsSystemdService()) + if (SystemdHelpers.IsSystemdLogger()) + { + AddSystemdLogger(services); + } + + if (SystemdHelpers.IsSystemdLifetime()) { AddSystemdLifetime(services); } + return services; } - private static void AddSystemdLifetime(IServiceCollection services) + private static void AddSystemdLogger(IServiceCollection services) { services.Configure(options => { options.FormatterName = ConsoleFormatterNames.Systemd; }); + } - // IsSystemdService() will never return true for android/browser/iOS/tvOS + private static void AddSystemdLifetime(IServiceCollection services) + { + // SystemdNotifier and SystemdLifetime are Unix-only; IsSystemdLifetime() ensures + // we only reach this code when running on Unix and when the environment indicates + // systemd-style integration (for example, when NOTIFY_SOCKET is set or the process + // is detected as a systemd service). #pragma warning disable CA1416 // Validate platform compatibility services.AddSingleton(); services.AddSingleton(); diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdNotifier.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdNotifier.cs index 791eda2a8413af..8a183fd04cd0b5 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdNotifier.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/src/SystemdNotifier.cs @@ -13,8 +13,6 @@ namespace Microsoft.Extensions.Hosting.Systemd [UnsupportedOSPlatform("browser")] public class SystemdNotifier : ISystemdNotifier { - private const string NOTIFY_SOCKET = "NOTIFY_SOCKET"; - private readonly string? _socketPath; /// @@ -60,13 +58,18 @@ public void Notify(ServiceState state) private static string? GetNotifySocketPath() { - string? socketPath = Environment.GetEnvironmentVariable(NOTIFY_SOCKET); + string? socketPath = Environment.GetEnvironmentVariable(SystemdConstants.NotifySocket); if (string.IsNullOrEmpty(socketPath)) { return null; } + // Because this method is called on Notifier construction, the envvar is cleared when the Host is built + // (IHostLifetime depends on ISystemdNotifier). This prevents child processes from inheriting the socket + // and interfering with service manager notifications. + Environment.SetEnvironmentVariable(SystemdConstants.NotifySocket, null); + // Support abstract socket paths. if (socketPath[0] == '@') { diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/Microsoft.Extensions.Hosting.Systemd.Tests.csproj b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/Microsoft.Extensions.Hosting.Systemd.Tests.csproj index 8ed239e3381135..8e342d4840c8e7 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/Microsoft.Extensions.Hosting.Systemd.Tests.csproj +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/Microsoft.Extensions.Hosting.Systemd.Tests.csproj @@ -3,10 +3,11 @@ $(NetCoreAppCurrent) true + true - + \ No newline at end of file diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/SystemdHelpersTests.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/SystemdHelpersTests.cs new file mode 100644 index 00000000000000..9d679df8f81469 --- /dev/null +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/SystemdHelpersTests.cs @@ -0,0 +1,148 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Globalization; +using Microsoft.DotNet.RemoteExecutor; +using Microsoft.Extensions.Hosting.Systemd; +using Xunit; + +namespace Microsoft.Extensions.Hosting +{ + public class SystemdHelpersTests + { + public static bool IsRemoteExecutorSupportedOnLinux => PlatformDetection.IsLinux && RemoteExecutor.IsSupported; + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsTrueWhenSystemdExecPidMatchesCurrentProcessId() + { + using var _ = RemoteExecutor.Invoke(static () => + { + string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId); + + Assert.True(SystemdHelpers.IsSystemdService()); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsFalseWhenSystemdExecPidDoesNotMatchOutsideSystemdSession() + { + using var _ = RemoteExecutor.Invoke(static () => + { + // When SYSTEMD_EXEC_PID is set but doesn't match the current PID, the code + // falls through to legacy detection. Outside a real systemd session + // (not PID 1, parent is not named "systemd"), the legacy path returns false. + // Note: the fall-through itself cannot be directly observed in a unit test + // without being PID 1 or having a parent named "systemd". + int nonMatchingPid = int.MaxValue; // No real process will ever have this PID. + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + Assert.False(SystemdHelpers.IsSystemdService()); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsFalseWhenSystemdExecPidIsAbsent() + { + using var _ = RemoteExecutor.Invoke(static () => + { + // When SYSTEMD_EXEC_PID is absent the code skips the v248+ path entirely + // and falls through to the legacy detection. Outside a real systemd session + // (not PID 1, parent is not systemd), the result must be false. + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + Assert.False(SystemdHelpers.IsSystemdService()); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsFalseWhenSystemdExecPidIsMalformed() + { + using var _ = RemoteExecutor.Invoke(static () => + { + // A malformed SYSTEMD_EXEC_PID must not be trusted: the logic falls through + // to the legacy /proc-based detection, which won't match outside a real systemd session. + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", "not-a-pid"); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + Assert.False(SystemdHelpers.IsSystemdService()); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceCachesFirstEvaluation() + { + using var _ = RemoteExecutor.Invoke(static () => + { + string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + int nonMatchingPid = int.MaxValue; // No real process will ever have this PID. + + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId); + bool firstEvaluation = SystemdHelpers.IsSystemdService(); + + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture)); + bool secondEvaluation = SystemdHelpers.IsSystemdService(); + + Assert.True(firstEvaluation); + Assert.True(secondEvaluation); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceCachesFirstNegativeEvaluation() + { + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + var firstEvaluation = SystemdHelpers.IsSystemdService(); + + string processId = Environment.ProcessId.ToString(CultureInfo.InvariantCulture); + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", processId); + + var secondEvaluation = SystemdHelpers.IsSystemdService(); + Assert.False(firstEvaluation); + Assert.False(secondEvaluation); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsFalseWhenSystemdExecPidDoesNotMatchAndNotifySocketIsSet() + { + // Child process scenario: SYSTEMD_EXEC_PID is set but doesn't match the current PID, + // while NOTIFY_SOCKET is inherited from the parent service. + // The mismatch falls through to legacy detection (not PID 1, parent not named "systemd"), + // which returns false. NOTIFY_SOCKET is only considered in the PID 1 container path. + using var _ = RemoteExecutor.Invoke(static () => + { + int nonMatchingPid = int.MaxValue; + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", nonMatchingPid.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify"); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + Assert.False(SystemdHelpers.IsSystemdService()); + }); + } + + [ConditionalFact(typeof(SystemdHelpersTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void IsSystemdServiceReturnsFalseWhenNotifySocketIsEmpty() + { + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", ""); + Environment.SetEnvironmentVariable("LISTEN_PID", null); + + Assert.False(SystemdHelpers.IsSystemdService()); + }); + } + } +} diff --git a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs index 948f9b4b527f16..0cafddd15eecf9 100644 --- a/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs +++ b/src/libraries/Microsoft.Extensions.Hosting.Systemd/tests/UseSystemdTests.cs @@ -1,41 +1,193 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System; +using System.Globalization; +using Microsoft.DotNet.RemoteExecutor; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting.Systemd; +using Microsoft.Extensions.Logging.Console; +using Microsoft.Extensions.Options; using Xunit; namespace Microsoft.Extensions.Hosting { public class UseSystemdTests { - [Fact] - public void DefaultsToOffOutsideOfService() + public static bool IsRemoteExecutorSupportedOnLinux => PlatformDetection.IsLinux && RemoteExecutor.IsSupported; + + private static IHost BuildHostWithAddSystemd() { - using IHost host = new HostBuilder() + var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings + { + // Disable defaults that may not be supported on the testing platform like EventLogLoggerProvider. + DisableDefaults = true, + }); + builder.Services.AddSystemd(); + return builder.Build(); + } + + private static IHost BuildHostWithUseSystemd() + { + return new HostBuilder() .UseSystemd() .Build(); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void AddSystemd_SystemdLoggerIsNotConfiguredAndSystemdLifetimeIsNotRegisteredWhenIsSystemdServiceIsFalse() + { + // Simulate running outside of a systemd service + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + + using IHost host = BuildHostWithAddSystemd(); + var options = host.Services.GetRequiredService>().Value; + Assert.NotEqual(ConsoleFormatterNames.Systemd, options.FormatterName); - var lifetime = host.Services.GetRequiredService(); - Assert.NotNull(lifetime); - Assert.IsNotType(lifetime); + var lifetime = host.Services.GetRequiredService(); + Assert.IsNotType(lifetime); + }); } - [Fact] - public void ServiceCollectionExtensionMethodDefaultsToOffOutsideOfService() + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void UseSystemd_SystemdLoggerIsNotConfiguredAndSystemdLifetimeIsNotRegisteredWhenIsSystemdServiceIsFalse() { - var builder = new HostApplicationBuilder(new HostApplicationBuilderSettings + // Simulate running outside of a systemd service + using var _ = RemoteExecutor.Invoke(static () => { - // Disable defaults that may not be supported on the testing platform like EventLogLoggerProvider. - DisableDefaults = true, + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + + using IHost host = BuildHostWithUseSystemd(); + var options = host.Services.GetRequiredService>().Value; + Assert.NotEqual(ConsoleFormatterNames.Systemd, options.FormatterName); + + var lifetime = host.Services.GetRequiredService(); + Assert.IsNotType(lifetime); }); + } - builder.Services.AddSystemd(); - using IHost host = builder.Build(); + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void AddSystemd_SystemdLoggerIsConfiguredAndSystemdLifetimeIsRegisteredWhenIsSystemdServiceIsTrue() + { + // Simulates: Type=simple with SYSTEMD_EXEC_PID (e.g. systemd >= v248) nominal case. + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + + using IHost host = BuildHostWithAddSystemd(); + var options = host.Services.GetRequiredService>().Value; + Assert.Equal(ConsoleFormatterNames.Systemd, options.FormatterName); + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); + }); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void UseSystemd_SystemdLoggerIsConfiguredAndSystemdLifetimeIsRegisteredWhenIsSystemdServiceIsTrue() + { + // Simulates: Type=simple with SYSTEMD_EXEC_PID (e.g. systemd >= v248) nominal case. + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", null); + + using IHost host = BuildHostWithUseSystemd(); + var options = host.Services.GetRequiredService>().Value; + Assert.Equal(ConsoleFormatterNames.Systemd, options.FormatterName); + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); + }); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void AddSystemd_SystemdLifetimeIsRegisteredAndLoggerIsNotConfiguredWhenOnlyNotifySocketIsSet() + { + // Simulates: Type=notify without SYSTEMD_EXEC_PID (e.g. containerized, Podman --sdnotify=container, systemd < v248). + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify"); + + using IHost host = BuildHostWithAddSystemd(); + + Assert.Null(Environment.GetEnvironmentVariable("NOTIFY_SOCKET")); // Cleared to prevent child process inheritance. + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); + + var options = host.Services.GetRequiredService>().Value; + Assert.NotEqual(ConsoleFormatterNames.Systemd, options.FormatterName); + }); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void UseSystemd_SystemdLifetimeIsRegisteredAndLoggerIsNotConfiguredWhenOnlyNotifySocketIsSet() + { + // Simulates: Type=notify without SYSTEMD_EXEC_PID (e.g. containerized, Podman --sdnotify=container, systemd < v248). + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", null); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify"); + + using IHost host = BuildHostWithUseSystemd(); + + Assert.Null(Environment.GetEnvironmentVariable("NOTIFY_SOCKET")); // Cleared to prevent child process inheritance. + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); - var lifetime = host.Services.GetRequiredService(); - Assert.NotNull(lifetime); - Assert.IsNotType(lifetime); + var options = host.Services.GetRequiredService>().Value; + Assert.NotEqual(ConsoleFormatterNames.Systemd, options.FormatterName); + }); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void AddSystemd_SystemdLoggerAndLifetimeAreBothRegisteredWhenBothConditionsAreMet() + { + // Simulates: Type=notify with SYSTEMD_EXEC_PID (systemd >= v248). + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify"); + + using IHost host = BuildHostWithAddSystemd(); + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); + + var options = host.Services.GetRequiredService>().Value; + Assert.Equal(ConsoleFormatterNames.Systemd, options.FormatterName); + }); + } + + [ConditionalFact(typeof(UseSystemdTests), nameof(IsRemoteExecutorSupportedOnLinux))] + public void UseSystemd_SystemdLoggerAndLifetimeAreBothRegisteredWhenBothConditionsAreMet() + { + // Simulates: Type=notify with SYSTEMD_EXEC_PID (systemd >= v248). + using var _ = RemoteExecutor.Invoke(static () => + { + Environment.SetEnvironmentVariable("SYSTEMD_EXEC_PID", + Environment.ProcessId.ToString(CultureInfo.InvariantCulture)); + Environment.SetEnvironmentVariable("NOTIFY_SOCKET", "/run/systemd/notify"); + + using IHost host = BuildHostWithUseSystemd(); + + var lifetime = host.Services.GetRequiredService(); + Assert.IsType(lifetime); + + var options = host.Services.GetRequiredService>().Value; + Assert.Equal(ConsoleFormatterNames.Systemd, options.FormatterName); + }); } } }