Skip to content

Commit 7d34df3

Browse files
committed
Log a message when the content root directory is likely wrong
1 parent 8752339 commit 7d34df3

4 files changed

Lines changed: 393 additions & 3 deletions

File tree

src/libraries/Microsoft.Extensions.Hosting/ref/Microsoft.Extensions.Hosting.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ public partial class ConsoleLifetime : Microsoft.Extensions.Hosting.IHostLifetim
131131
{
132132
public ConsoleLifetime(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.ConsoleLifetimeOptions> options, Microsoft.Extensions.Hosting.IHostEnvironment environment, Microsoft.Extensions.Hosting.IHostApplicationLifetime applicationLifetime, Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.HostOptions> hostOptions) { }
133133
public ConsoleLifetime(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.ConsoleLifetimeOptions> options, Microsoft.Extensions.Hosting.IHostEnvironment environment, Microsoft.Extensions.Hosting.IHostApplicationLifetime applicationLifetime, Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.HostOptions> hostOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory) { }
134+
public ConsoleLifetime(Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.ConsoleLifetimeOptions> options, Microsoft.Extensions.Hosting.IHostEnvironment environment, Microsoft.Extensions.Hosting.IHostApplicationLifetime applicationLifetime, Microsoft.Extensions.Options.IOptions<Microsoft.Extensions.Hosting.HostOptions> hostOptions, Microsoft.Extensions.Logging.ILoggerFactory loggerFactory, Microsoft.Extensions.Configuration.IConfiguration? configuration) { }
134135
public void Dispose() { }
135136
public System.Threading.Tasks.Task StopAsync(System.Threading.CancellationToken cancellationToken) { throw null; }
136137
public System.Threading.Tasks.Task WaitForStartAsync(System.Threading.CancellationToken cancellationToken) { throw null; }

src/libraries/Microsoft.Extensions.Hosting/src/Internal/ConsoleLifetime.cs

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System;
5+
using System.IO;
56
using System.Runtime.Versioning;
7+
using System.Security;
68
using System.Threading;
79
using System.Threading.Tasks;
10+
using Microsoft.Extensions.Configuration;
11+
using Microsoft.Extensions.FileProviders;
812
using Microsoft.Extensions.Logging;
913
using Microsoft.Extensions.Logging.Abstractions;
1014
using Microsoft.Extensions.Options;
@@ -44,6 +48,19 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
4448
/// <param name="loggerFactory">An object to configure the logging system and create instances of <see cref="ILogger"/> from the registered <see cref="ILoggerProvider"/>.</param>
4549
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="environment"/> or <paramref name="applicationLifetime"/> or <paramref name="hostOptions"/> or <paramref name="loggerFactory"/> is <see langword="null"/>.</exception>
4650
public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions<HostOptions> hostOptions, ILoggerFactory loggerFactory)
51+
: this(options, environment, applicationLifetime, hostOptions, loggerFactory, configuration: null) { }
52+
53+
/// <summary>
54+
/// Initializes a <see cref="ConsoleLifetime"/> instance using the specified console lifetime options, host environment, host options, logger factory, and application configuration.
55+
/// </summary>
56+
/// <param name="options">An object used to retrieve <see cref="ConsoleLifetimeOptions"/> instances.</param>
57+
/// <param name="environment">Information about the hosting environment an application is running in.</param>
58+
/// <param name="applicationLifetime">An object that allows consumers to be notified of application lifetime events.</param>
59+
/// <param name="hostOptions">An object used to retrieve <see cref="HostOptions"/> instances.</param>
60+
/// <param name="loggerFactory">An object to configure the logging system and create instances of <see cref="ILogger"/> from the registered <see cref="ILoggerProvider"/>.</param>
61+
/// <param name="configuration">The application's <see cref="IConfiguration"/>, used to inspect file-based configuration sources for diagnostic logging. May be <see langword="null"/>.</param>
62+
/// <exception cref="ArgumentNullException"><paramref name="options"/> or <paramref name="environment"/> or <paramref name="applicationLifetime"/> or <paramref name="hostOptions"/> or <paramref name="loggerFactory"/> is <see langword="null"/>.</exception>
63+
public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironment environment, IHostApplicationLifetime applicationLifetime, IOptions<HostOptions> hostOptions, ILoggerFactory loggerFactory, IConfiguration? configuration)
4764
{
4865
ArgumentNullException.ThrowIfNull(options?.Value, nameof(options));
4966
ArgumentNullException.ThrowIfNull(applicationLifetime);
@@ -55,6 +72,7 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
5572
Environment = environment;
5673
ApplicationLifetime = applicationLifetime;
5774
HostOptions = hostOptions.Value;
75+
Configuration = configuration;
5876
Logger = loggerFactory.CreateLogger("Microsoft.Hosting.Lifetime");
5977
}
6078

@@ -66,6 +84,8 @@ public ConsoleLifetime(IOptions<ConsoleLifetimeOptions> options, IHostEnvironmen
6684

6785
private HostOptions HostOptions { get; }
6886

87+
private IConfiguration? Configuration { get; }
88+
6989
private ILogger Logger { get; }
7090

7191
/// <summary>
@@ -77,6 +97,16 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
7797
{
7898
if (!Options.SuppressStatusMessages)
7999
{
100+
// Log a diagnostic when the content root is the current working directory and looks
101+
// suspicious. Logging here (rather than in OnApplicationStarted) ensures the message
102+
// is still emitted when the host fails to start (e.g. a hosted service can't find
103+
// appsettings.json because the working directory is unintentionally "/" in a
104+
// container without WORKDIR or when launched by systemd without WorkingDirectory).
105+
if (Logger.IsEnabled(LogLevel.Information) && ShouldWarnAboutContentRoot())
106+
{
107+
Logger.LogInformation("Content root path is the current working directory ({ContentRoot}). To override, set the content root explicitly.", Environment.ContentRootPath);
108+
}
109+
80110
_applicationStartedRegistration = ApplicationLifetime.ApplicationStarted.Register(state =>
81111
{
82112
((ConsoleLifetime)state!).OnApplicationStarted();
@@ -95,6 +125,106 @@ public Task WaitForStartAsync(CancellationToken cancellationToken)
95125
return Task.CompletedTask;
96126
}
97127

128+
private bool ShouldWarnAboutContentRoot()
129+
{
130+
try
131+
{
132+
string contentRootPath = Environment.ContentRootPath;
133+
134+
// Hosting does not normalize ContentRootPath, so an exact match
135+
// indicates the working directory is being used as the content root.
136+
// A user-specified content root that happens to expand to the same directory
137+
// but as a different string (e.g. with a trailing separator, or via "./")
138+
// is assumed to be intentional.
139+
if (!string.Equals(contentRootPath, Directory.GetCurrentDirectory(), StringComparison.Ordinal))
140+
{
141+
return false;
142+
}
143+
144+
// Case 1: the content root is a filesystem root (e.g. "/" or "C:\").
145+
// Almost certainly not where the user intended their app to be rooted - log
146+
// regardless of how it got set.
147+
if (string.Equals(Path.GetPathRoot(contentRootPath), contentRootPath, StringComparison.Ordinal))
148+
{
149+
return true;
150+
}
151+
152+
// Case 2: at least one file-based configuration source is rooted at the content
153+
// root but none of those files exist on disk. This typically means appsettings.json
154+
// was expected (hosting defaults registered it) but the working directory doesn't
155+
// actually contain it - a sign the working directory is not the intended app
156+
// directory.
157+
return AllContentRootFileSourcesAreMissing(contentRootPath);
158+
}
159+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException)
160+
{
161+
// This diagnostic is a heuristic. I/O and security failures from
162+
// querying the current directory or probing file-based configuration providers
163+
// should simply skip the diagnostic.
164+
return false;
165+
}
166+
}
167+
168+
private bool AllContentRootFileSourcesAreMissing(string contentRootPath)
169+
{
170+
if (Configuration is not IConfigurationRoot configRoot)
171+
{
172+
return false;
173+
}
174+
175+
bool sawContentRootSource = false;
176+
177+
foreach (IConfigurationProvider provider in configRoot.Providers)
178+
{
179+
if (provider is not FileConfigurationProvider fileProvider)
180+
{
181+
continue;
182+
}
183+
184+
FileConfigurationSource source = fileProvider.Source;
185+
if (source.FileProvider is not PhysicalFileProvider physicalProvider)
186+
{
187+
// We can only compare paths against the content root for physical providers.
188+
continue;
189+
}
190+
191+
if (!TrimTrailingDirectorySeparator(physicalProvider.Root).Equals(contentRootPath.AsSpan(), StringComparison.Ordinal))
192+
{
193+
continue;
194+
}
195+
196+
if (source.Path is not string sourcePath)
197+
{
198+
continue;
199+
}
200+
201+
sawContentRootSource = true;
202+
203+
if (source.FileProvider.GetFileInfo(sourcePath).Exists)
204+
{
205+
return false;
206+
}
207+
}
208+
209+
return sawContentRootSource;
210+
}
211+
212+
private static ReadOnlySpan<char> TrimTrailingDirectorySeparator(string path)
213+
{
214+
if (path.Length <= 1)
215+
{
216+
return path;
217+
}
218+
219+
char last = path[path.Length - 1];
220+
if (last == Path.DirectorySeparatorChar || last == Path.AltDirectorySeparatorChar)
221+
{
222+
return path.AsSpan(0, path.Length - 1);
223+
}
224+
225+
return path;
226+
}
227+
98228
private partial void RegisterShutdownHandlers();
99229

100230
private void OnApplicationStarted()

0 commit comments

Comments
 (0)