Skip to content
Draft
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
7 changes: 4 additions & 3 deletions Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
PackageId.targets file which brings the ConfigurationSchema.json file into the Json Schema.
-->
<PropertyGroup>
<ConfigurationSchemaGeneratorEnabled Condition="'$(ConfigurationSchemaGeneratorEnabled)' == ''">true</ConfigurationSchemaGeneratorEnabled>
<ConfigurationSchemaPath>$(MSBuildProjectDirectory)\ConfigurationSchema.json</ConfigurationSchemaPath>
<ConfigurationSchemaExists Condition="Exists('$(ConfigurationSchemaPath)')">true</ConfigurationSchemaExists>
</PropertyGroup>
Expand All @@ -39,15 +40,15 @@
<!--
Logic for generating and comparing the ConfigurationSchema.json file
-->
<PropertyGroup Condition="'$(IsPackable)' == 'true'">
<PropertyGroup Condition="'$(IsPackable)' == 'true' AND '$(ConfigurationSchemaGeneratorEnabled)' == 'true'">
<TargetsTriggeredByCompilation Condition="'$(DesignTimeBuild)' != 'true'">$(TargetsTriggeredByCompilation);GenerateConfigurationSchema</TargetsTriggeredByCompilation>

<ConfigurationSchemaGeneratorProjectPath>$(MSBuildThisFileDirectory)src\Tools\src\ConfigurationSchemaGenerator\ConfigurationSchemaGenerator.csproj</ConfigurationSchemaGeneratorProjectPath>
<ConfigurationSchemaGeneratorRspPath>$(IntermediateOutputPath)$(AsemblyName).configschema.rsp</ConfigurationSchemaGeneratorRspPath>
<ConfigurationSchemaGeneratorRspPath>$(IntermediateOutputPath)$(AssemblyName).configschema.rsp</ConfigurationSchemaGeneratorRspPath>
<GeneratedConfigurationSchemaOutputPath>$(IntermediateOutputPath)ConfigurationSchema.json</GeneratedConfigurationSchemaOutputPath>
</PropertyGroup>

<ItemGroup Condition="'$(IsPackable)' == 'true'">
<ItemGroup Condition="'$(IsPackable)' == 'true' AND '$(ConfigurationSchemaGeneratorEnabled)' == 'true'">
<!-- ensure the config generator is built -->
<ProjectReference Include="$(ConfigurationSchemaGeneratorProjectPath)"
Private="false"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ internal sealed partial class GitInfoContributor : ConfigurationContributor, IIn
private readonly ILogger _logger;

public GitInfoContributor(ILogger<GitInfoContributor> logger)
: this($"{Directory.GetCurrentDirectory()}{Path.DirectorySeparatorChar}{GitPropertiesFileName}", logger)
: this(ResolveDefaultPropertiesPath(), logger)
{
}

Expand All @@ -33,6 +33,30 @@ public GitInfoContributor(string propertiesPath, ILogger<GitInfoContributor> log
_logger = logger;
}

private static string ResolveDefaultPropertiesPath()
{
return ResolveDefaultPropertiesPath(AppContext.BaseDirectory, Directory.GetCurrentDirectory());
}

/// <summary>
/// Prefers the directory the running assembly was loaded from (where a build tool like Steeltoe.Management.GitProperties.Build copies git.properties to)
/// over the process's current working directory, since the latter depends entirely on how the application was launched (for example, `dotnet run` and
/// directly invoking a built DLL both leave the current directory pointed at the project directory, not the output directory the assembly - and
/// git.properties - actually live in) and can't be relied on to match. Takes both directories as parameters purely so tests can exercise this resolution
/// logic against isolated temporary directories, without touching either of this process's real ones.
/// </summary>
internal static string ResolveDefaultPropertiesPath(string baseDirectory, string currentDirectory)
{
string baseDirectoryPath = Path.Combine(baseDirectory, GitPropertiesFileName);

if (File.Exists(baseDirectoryPath))
{
return baseDirectoryPath;
}

return Path.Combine(currentDirectory, GitPropertiesFileName);
}

public async Task ContributeAsync(InfoBuilder builder, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(builder);
Expand Down
147 changes: 147 additions & 0 deletions src/Management/src/GitProperties.Build/AtomicFile.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Text;

namespace Steeltoe.Management.GitProperties.Build;

/// <summary>
/// Safely reads, writes, and locks files that multiple projects and target frameworks in a solution build may touch at the same time.
/// </summary>
/// <remarks>
/// MSBuild builds multiple projects - and multiple target frameworks of the same project - concurrently by default. When several of those share a single
/// file on disk, two problems follow: a reader can open the file at the exact moment a writer is replacing its content, and two writers can try to
/// update the same file at once. Plain file I/O handles neither case reliably, so every method here is built to tolerate both: writes go through a swap
/// that never leaves the file half-written, reads and writes both retry briefly if they land on the wrong side of someone else's swap, and an optional
/// lock lets concurrent writers avoid redoing the same expensive work instead of every one of them racing to produce the same result.
/// </remarks>
internal static class AtomicFile
{
/// <summary>
/// How many times a failed read or write is retried, and how long to wait between attempts.
/// </summary>
private const int MaxAttempts = 10;

private static readonly TimeSpan ReadWriteRetryDelay = TimeSpan.FromMilliseconds(100);
private static readonly TimeSpan AcquireLockRetryDelay = TimeSpan.FromMilliseconds(50);

#pragma warning disable S6354 // Use a testable date/time provider
// Justification: System.TimeProvider isn't available on netstandard2.0.
private static DateTime CurrentTimeUtc => DateTime.UtcNow;
#pragma warning restore S6354 // Use a testable date/time provider

/// <summary>
/// Writes the given lines to <paramref name="path" /> so that anyone reading it - even concurrently - only ever sees the complete previous content or
/// the complete new content, never a partial write. Retries briefly if another process is momentarily in the way.
/// </summary>
public static void WriteAtomic(string path, List<string> lines)
{
string? directory = Path.GetDirectoryName(path);

if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}

string tempPath = Path.Combine(directory ?? string.Empty, $"{Path.GetRandomFileName()}~");
var encoding = new UTF8Encoding(false);
Exception? lastError = null;

for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
File.WriteAllText(tempPath, $"{string.Join("\n", lines)}\n", encoding);
MoveOrReplace(tempPath, path);
return;
}
catch (Exception exception) when (IsTransientError(exception))
{
lastError = exception;
Thread.Sleep(ReadWriteRetryDelay);
}
}

throw new IOException($"Failed to write {path} after {MaxAttempts} attempts.", lastError);
}

/// <summary>
/// Reads all lines from <paramref name="path" />, retrying briefly if another process is momentarily in the way - the read-side counterpart to
/// <see cref="WriteAtomic" />.
/// </summary>
public static string[] ReadAllLinesWithRetry(string path)
{
Exception? lastError = null;

for (int attempt = 1; attempt <= MaxAttempts; attempt++)
{
try
{
return File.ReadAllLines(path);
}
catch (Exception exception) when (IsTransientError(exception))
{
lastError = exception;
Thread.Sleep(ReadWriteRetryDelay);
}
}

throw new IOException($"Failed to read {path} after {MaxAttempts} attempts.", lastError);
}

/// <summary>
/// Moves a freshly-written file into place, whether or not something is already there.
/// </summary>
private static void MoveOrReplace(string sourcePath, string destinationPath)
{
try
{
File.Move(sourcePath, destinationPath);
}
catch (IOException) when (File.Exists(destinationPath))
{
File.Replace(sourcePath, destinationPath, null);
}
}

/// <summary>
/// Attempts to become the sole holder of <paramref name="lockFilePath" /> across every process trying to acquire it, for up to
/// <paramref name="timeout" />. Returns null if that doesn't happen in time, or if locking isn't possible at all in this environment - holding this lock
/// is only ever an optimization (letting one process do some expensive work while everyone else waits and reuses the result, instead of every process
/// redoing it independently), never a correctness requirement, so callers must always have a safe fallback for when it can't be acquired.
/// </summary>
public static FileStream? TryAcquireExclusiveLock(string lockFilePath, TimeSpan timeout)
{
string? directory = Path.GetDirectoryName(lockFilePath);

if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}

DateTime deadlineUtc = CurrentTimeUtc + timeout;

while (true)
{
try
{
return new FileStream(lockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
}
catch (Exception exception) when (IsTransientError(exception))
{
if (CurrentTimeUtc >= deadlineUtc)
{
return null;
}

Thread.Sleep(AcquireLockRetryDelay);
}
}
}

private static bool IsTransientError(Exception exception)
{
return exception is IOException or UnauthorizedAccessException;
}
}
142 changes: 142 additions & 0 deletions src/Management/src/GitProperties.Build/ComposeGitPropertiesTask.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information.

using System.Globalization;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Steeltoe.Management.GitProperties.Build;

/// <summary>
/// Merges the shared cache (see <see cref="GenerateGitPropertiesCacheTask" />) with the fields that can never be cached across projects: the live
/// working-tree dirty state, the per-project $(Version), and the current build's own timestamp. Runs once per project, every build - unlike the cache
/// generation, this is deliberately not skippable via Inputs/Outputs, since editing a tracked file doesn't touch any file timestamp this task could key
/// incrementality off, and a cached build time would go stale the moment it's reused by a second build. Every failure below returns false: this task's
/// own output (and the fallback copy, if configured) must never end up mismatched with what's actually on disk, so a partial run must fail the build
/// loudly rather than let a later step (the copy-to-output/publish targets) pick up stale or incomplete content.
/// </summary>
// ReSharper disable once UnusedType.Global
public sealed class ComposeGitPropertiesTask : Task
{
/// <summary>
/// Gets or sets the resolved git repository root directory.
/// </summary>
[Required]
public string RepositoryRoot { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the git executable to invoke.
/// </summary>
[Required]
public string GitExecutable { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the shared cache file to read from.
/// </summary>
[Required]
public string CacheFile { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the per-project output file to write.
/// </summary>
[Required]
public string OutputFile { get; set; } = string.Empty;

/// <summary>
/// Gets or sets the consuming project's $(Version), written as git.build.version.
/// </summary>
public string? Version { get; set; }

/// <summary>
/// Gets or sets an optional additional path to copy the same composed content to - the durable fallback file that
/// Steeltoe.Management.GitProperties.Build.targets' IncludeGitPropertiesInOutput target later falls back to when a build has no usable git repository at
/// all (e.g. a source-based `cf push`, where .git is excluded from the pushed tree by default). Empty (the default) is a no-op; only non-empty when the
/// consumer opted into $(GitPropertiesWriteToProjectDirectory).
/// </summary>
public string? FallbackFile { get; set; }

/// <inheritdoc />
public override bool Execute()
{
int exitCode;
string stdout;
string stderr;

try
{
exitCode = GitProcessRunner.Run(GitExecutable, RepositoryRoot, "status --porcelain", out stdout, out stderr);
}
catch (Exception exception)
{
Log.LogError($"git.properties: an unexpected error occurred while determining working tree status:{Environment.NewLine}{exception}");
return false;
}

if (exitCode != 0)
{
Log.LogError("git.properties: failed to determine working tree status: {0}", stderr);
return false;
}

bool isDirty = stdout.Length > 0;
List<string> lines = [];

if (!TryRunFileOperation($"read {CacheFile}", () => lines = AtomicFile.ReadAllLinesWithRetry(CacheFile).ToList()))
{
return false;
}

for (int index = 0; index < lines.Count; index++)
{
if (isDirty && lines[index].StartsWith($"{GitPropertiesFormat.CommitIdDescribeKey}=", StringComparison.Ordinal))
{
lines[index] += "-dirty";
}
}

lines.Add($"git.dirty={(isDirty ? "true" : "false")}");
lines.Add($"git.build.version={GitPropertiesFormat.EscapeLineBreaks(Version)}");

// S6354 (use an injectable time provider): not practical here, for the same reason as
// AtomicFile.TryAcquireExclusiveLock - see that method's remarks. Matches the
// ISO-8601-with-offset style git itself uses for git.commit.time (%cI), rather than
// normalizing to UTC - this is "when this build ran, in the build machine's own local time",
// not a value that needs to compare directly against the commit's own timestamp.
#pragma warning disable S6354
string buildTime = DateTimeOffset.Now.ToString("yyyy-MM-ddTHH:mm:sszzz", CultureInfo.InvariantCulture);
#pragma warning restore S6354
lines.Add($"git.build.time={buildTime}");

if (!TryRunFileOperation($"write {OutputFile}", () => AtomicFile.WriteAtomic(OutputFile, lines)))
{
return false;
}

if (FallbackFile is null or "")
{
return true;
}

return TryRunFileOperation($"write fallback file {FallbackFile}", () => AtomicFile.WriteAtomic(FallbackFile, lines));
}

/// <summary>
/// Runs a file-system operation that must succeed for this task to succeed - the shape shared by every step below that isn't the initial git status
/// check (which has its own, dual failure modes - see <see cref="Execute" />). Logs a build error and returns false if <paramref name="action" />
/// throws.
/// </summary>
private bool TryRunFileOperation(string description, Action action)
{
try
{
action();
return true;
}
catch (Exception exception)
{
Log.LogError($"git.properties: failed to {description}:{Environment.NewLine}{exception}");
return false;
}
}
}
Loading