Skip to content

Redirect health check #1340

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Jun 16, 2025
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
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@ Options:
--force <bool?> Force a full rebuild of the destination folder (Default: null)

Commands:
generate Converts a source markdown folder or file to an output folder
serve Continuously serve a documentation folder at http://localhost:3000.
generate Converts a source markdown folder or file to an output folder
serve Continuously serve a documentation folder at http://localhost:3000.
diff validate Validates redirect rules have been applied to the current branch.
File systems changes will be reflected without having to restart the server.
```

Expand Down Expand Up @@ -118,6 +119,16 @@ https://github.com/elastic/{your-repository}/settings/pages

---

## Validating redirection rules

If documentation is moved, renamed or deleted, `docs-builder` can verify if changes in the working branch in relation to the default branch are reflected in the repository's `redirects.yml`. Verification in the local machine is currently supported.

`docs-builder diff validate <path>`

`<path>` is an optional parameter to customize the documentation folder path. It defaults to `docs`.

---

## Run without docker

You can use the .NET CLI to publish a self-contained `docs-builder` native code
Expand All @@ -140,7 +151,7 @@ existing surveyed tools

# Local Development

## Preqrequisites
## Prerequisites

- [.NET 9.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
- [Node.js 22.13.1 (LTS)](https://nodejs.org/en/blog/release/v22.13.1)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<PackageReference Include="Github.Actions.Core" />
<PackageReference Include="Crayon" />
<PackageReference Include="Errata" />
<PackageReference Include="Proc" />
</ItemGroup>

<ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.IO.Abstractions;
using Elastic.Documentation.Diagnostics;
using ProcNet;
using ProcNet.Std;

namespace Elastic.Documentation.Tooling.ExternalCommands;

public abstract class ExternalCommandExecutor(DiagnosticsCollector collector, IDirectoryInfo workingDirectory)
{
protected IDirectoryInfo WorkingDirectory => workingDirectory;
protected void ExecIn(Dictionary<string, string> environmentVars, string binary, params string[] args)
{
var arguments = new ExecArguments(binary, args)
{
WorkingDirectory = workingDirectory.FullName,
Environment = environmentVars
};
var result = Proc.Exec(arguments);
if (result != 0)
collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
}

protected void ExecInSilent(Dictionary<string, string> environmentVars, string binary, params string[] args)
{
var arguments = new StartArguments(binary, args)
{
Environment = environmentVars,
WorkingDirectory = workingDirectory.FullName,
ConsoleOutWriter = NoopConsoleWriter.Instance
};
var result = Proc.Start(arguments);
if (result.ExitCode != 0)
collector.EmitError("", $"Exit code: {result.ExitCode} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
}

protected string[] CaptureMultiple(string binary, params string[] args)
{
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
Exception? e = null;
for (var i = 0; i <= 9; i++)
{
try
{
return CaptureOutput();
}
catch (Exception ex)
{
if (ex is not null)
e = ex;
}
}

if (e is not null)
collector.EmitError("", "failure capturing stdout", e);

return [];

string[] CaptureOutput()
{
var arguments = new StartArguments(binary, args)
{
WorkingDirectory = workingDirectory.FullName,
Timeout = TimeSpan.FromSeconds(3),
WaitForExit = TimeSpan.FromSeconds(3),
ConsoleOutWriter = NoopConsoleWriter.Instance
};
var result = Proc.Start(arguments);
var output = result.ExitCode != 0
? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
: result.ConsoleOut.Select(x => x.Line).ToArray() ?? throw new Exception($"No output captured for {binary}: {workingDirectory}");
return output;
}
}


protected string Capture(string binary, params string[] args) => Capture(false, binary, args);

protected string Capture(bool muteExceptions, string binary, params string[] args)
{
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
Exception? e = null;
for (var i = 0; i <= 9; i++)
{
try
{
return CaptureOutput();
}
catch (Exception ex)
{
if (ex is not null)
e = ex;
}
}

if (e is not null && !muteExceptions)
collector.EmitError("", "failure capturing stdout", e);

return string.Empty;

string CaptureOutput()
{
var arguments = new StartArguments(binary, args)
{
WorkingDirectory = workingDirectory.FullName,
Timeout = TimeSpan.FromSeconds(3),
WaitForExit = TimeSpan.FromSeconds(3),
ConsoleOutWriter = NoopConsoleWriter.Instance
};
var result = Proc.Start(arguments);
var line = (result.ExitCode, muteExceptions) switch
{
(0, _) or (not 0, true) => result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"),
(not 0, false) => throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
};
return line;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using ProcNet.Std;

namespace Elastic.Documentation.Tooling.ExternalCommands;

public class NoopConsoleWriter : IConsoleOutWriter
{
public static readonly NoopConsoleWriter Instance = new();

public void Write(Exception e) { }

public void Write(ConsoleOut consoleOut) { }
}
82 changes: 17 additions & 65 deletions src/tooling/docs-assembler/Sourcing/GitFacade.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

using System.IO.Abstractions;
using Elastic.Documentation.Diagnostics;
using ProcNet;
using Elastic.Documentation.Tooling.ExternalCommands;

namespace Documentation.Assembler.Sourcing;

Expand All @@ -24,73 +24,25 @@ public interface IGitRepository

// This git repository implementation is optimized for pull and fetching single commits.
// It uses `git pull --depth 1` and `git fetch --depth 1` to minimize the amount of data transferred.
public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : IGitRepository
public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IGitRepository
{
public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD");

public void Init() => ExecIn("git", "init");
public bool IsInitialized() => Directory.Exists(Path.Combine(workingDirectory.FullName, ".git"));
public void Pull(string branch) => ExecIn("git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch);
public void Fetch(string reference) => ExecIn("git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference);
public void EnableSparseCheckout(string folder) => ExecIn("git", "sparse-checkout", "set", folder);
public void DisableSparseCheckout() => ExecIn("git", "sparse-checkout", "disable");
public void Checkout(string reference) => ExecIn("git", "checkout", "--force", reference);

public void GitAddOrigin(string origin) => ExecIn("git", "remote", "add", "origin", origin);

private void ExecIn(string binary, params string[] args)
{
var arguments = new ExecArguments(binary, args)
{
WorkingDirectory = workingDirectory.FullName,
Environment = new Dictionary<string, string>
{
// Disable git editor prompts:
// There are cases where `git pull` would prompt for an editor to write a commit message.
// This env variable prevents that.
{ "GIT_EDITOR", "true" }
},
};
var result = Proc.Exec(arguments);
if (result != 0)
collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
}
private string Capture(string binary, params string[] args)
private static readonly Dictionary<string, string> EnvironmentVars = new()
{
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
Exception? e = null;
for (var i = 0; i <= 9; i++)
{
try
{
return CaptureOutput();
}
catch (Exception ex)
{
if (ex is not null)
e = ex;
}
}
// Disable git editor prompts:
// There are cases where `git pull` would prompt for an editor to write a commit message.
// This env variable prevents that.
{ "GIT_EDITOR", "true" }
};

if (e is not null)
collector.EmitError("", "failure capturing stdout", e);
public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD");

return string.Empty;
public void Init() => ExecIn(EnvironmentVars, "git", "init");
public bool IsInitialized() => Directory.Exists(Path.Combine(WorkingDirectory.FullName, ".git"));
public void Pull(string branch) => ExecIn(EnvironmentVars, "git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch);
public void Fetch(string reference) => ExecIn(EnvironmentVars, "git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference);
public void EnableSparseCheckout(string folder) => ExecIn(EnvironmentVars, "git", "sparse-checkout", "set", folder);
public void DisableSparseCheckout() => ExecIn(EnvironmentVars, "git", "sparse-checkout", "disable");
public void Checkout(string reference) => ExecIn(EnvironmentVars, "git", "checkout", "--force", reference);

string CaptureOutput()
{
var arguments = new StartArguments(binary, args)
{
WorkingDirectory = workingDirectory.FullName,
Timeout = TimeSpan.FromSeconds(3),
WaitForExit = TimeSpan.FromSeconds(3),
ConsoleOutWriter = NoopConsoleWriter.Instance
};
var result = Proc.Start(arguments);
var line = result.ExitCode != 0
? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
: result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}");
return line;
}
}
public void GitAddOrigin(string origin) => ExecIn(EnvironmentVars, "git", "remote", "add", "origin", origin);
}
Original file line number Diff line number Diff line change
Expand Up @@ -237,15 +237,6 @@ private static void FetchAndCheckout(IGitRepository git, Repository repository,
}
}

public class NoopConsoleWriter : IConsoleOutWriter
{
public static readonly NoopConsoleWriter Instance = new();

public void Write(Exception e) { }

public void Write(ConsoleOut consoleOut) { }
}

public record CheckoutResult
{
public static string LinkRegistrySnapshotFileName => "link-index.snapshot.json";
Expand Down
78 changes: 78 additions & 0 deletions src/tooling/docs-builder/Cli/DiffCommands.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Licensed to Elasticsearch B.V under one or more agreements.
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
// See the LICENSE file in the project root for more information

using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Actions.Core.Services;
using ConsoleAppFramework;
using Documentation.Builder.Tracking;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Builder;
using Elastic.Documentation.Tooling.Diagnostics.Console;
using Elastic.Documentation.Tooling.Filters;
using Microsoft.Extensions.Logging;

namespace Documentation.Builder.Cli;

internal sealed class DiffCommands(ILoggerFactory logger, ICoreService githubActionsService)
{
/// <summary>
/// Validates redirect updates in the current branch using the redirects file against changes reported by git.
/// </summary>
/// <param name="path">The baseline path to perform the check</param>
/// <param name="ctx"></param>
[SuppressMessage("Usage", "CA2254:Template should be a static expression")]
[Command("validate")]
[ConsoleAppFilter<StopwatchFilter>]
[ConsoleAppFilter<CatchExceptionFilter>]
public async Task<int> ValidateRedirects([Argument] string? path = null, Cancel ctx = default)
{
var log = logger.CreateLogger<Program>();
ConsoleApp.Log = msg => log.LogInformation(msg);
ConsoleApp.LogError = msg => log.LogError(msg);

path ??= "docs";

await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx);

var fs = new FileSystem();
var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName);

var buildContext = new BuildContext(collector, fs, fs, root.FullName, null);
var sourceFile = buildContext.ConfigurationPath;
var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName));

var redirectFileParser = new RedirectFile(redirectFileInfo, buildContext);
var redirects = redirectFileParser.Redirects;

if (redirects is null)
{
collector.EmitError(redirectFileInfo, "It was not possible to parse the redirects file.");
await collector.StopAsync(ctx);
return collector.Errors;
}

var tracker = new LocalGitRepositoryTracker(collector, root);
var changed = tracker.GetChangedFiles(path);

foreach (var notFound in changed.DistinctBy(c => c.FilePath).Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed
&& !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath)))
{
if (notFound is RenamedGitChange renamed)
{
collector.EmitError(redirectFileInfo.Name,
$"File '{renamed.OldFilePath}' was renamed to '{renamed.NewFilePath}' but it has no redirect configuration set.");
}
else if (notFound.ChangeType is GitChangeType.Deleted)
{
collector.EmitError(redirectFileInfo.Name,
$"File '{notFound.FilePath}' was deleted but it has no redirect targets. This will lead to broken links.");
}
}

await collector.StopAsync(ctx);
return collector.Errors;
}
}
1 change: 1 addition & 0 deletions src/tooling/docs-builder/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
var app = ConsoleApp.Create();
app.Add<Commands>();
app.Add<InboundLinkCommands>("inbound-links");
app.Add<DiffCommands>("diff");

await app.RunAsync(args).ConfigureAwait(false);
Loading
Loading