Skip to content

Commit 469c825

Browse files
authored
Redirect health check (#1340)
* Refactor location of reusable external command handler functionality * Capture moved/deleted from docs * Environment variables should be an argument as the context may differ between external execution interfaces. * Introduce records to keep track of git change data, and leverage them to make clearer reporting. * Capture local unstaged changes * Introduce silent execution method for ExternalCommandExecutor.
1 parent 06c7fff commit 469c825

File tree

10 files changed

+354
-77
lines changed

10 files changed

+354
-77
lines changed

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ Options:
2525
--force <bool?> Force a full rebuild of the destination folder (Default: null)
2626

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

@@ -118,6 +119,16 @@ https://github.com/elastic/{your-repository}/settings/pages
118119
119120
---
120121
122+
## Validating redirection rules
123+
124+
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.
125+
126+
`docs-builder diff validate <path>`
127+
128+
`<path>` is an optional parameter to customize the documentation folder path. It defaults to `docs`.
129+
130+
---
131+
121132
## Run without docker
122133

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

141152
# Local Development
142153

143-
## Preqrequisites
154+
## Prerequisites
144155

145156
- [.NET 9.0 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/9.0)
146157
- [Node.js 22.13.1 (LTS)](https://nodejs.org/en/blog/release/v22.13.1)

src/tooling/Elastic.Documentation.Tooling/Elastic.Documentation.Tooling.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageReference Include="GitHub.Actions.Core" />
1414
<PackageReference Include="Crayon" />
1515
<PackageReference Include="Errata" />
16+
<PackageReference Include="Proc" />
1617
</ItemGroup>
1718

1819
<ItemGroup>
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.IO.Abstractions;
6+
using Elastic.Documentation.Diagnostics;
7+
using ProcNet;
8+
using ProcNet.Std;
9+
10+
namespace Elastic.Documentation.Tooling.ExternalCommands;
11+
12+
public abstract class ExternalCommandExecutor(DiagnosticsCollector collector, IDirectoryInfo workingDirectory)
13+
{
14+
protected IDirectoryInfo WorkingDirectory => workingDirectory;
15+
protected void ExecIn(Dictionary<string, string> environmentVars, string binary, params string[] args)
16+
{
17+
var arguments = new ExecArguments(binary, args)
18+
{
19+
WorkingDirectory = workingDirectory.FullName,
20+
Environment = environmentVars
21+
};
22+
var result = Proc.Exec(arguments);
23+
if (result != 0)
24+
collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
25+
}
26+
27+
protected void ExecInSilent(Dictionary<string, string> environmentVars, string binary, params string[] args)
28+
{
29+
var arguments = new StartArguments(binary, args)
30+
{
31+
Environment = environmentVars,
32+
WorkingDirectory = workingDirectory.FullName,
33+
ConsoleOutWriter = NoopConsoleWriter.Instance
34+
};
35+
var result = Proc.Start(arguments);
36+
if (result.ExitCode != 0)
37+
collector.EmitError("", $"Exit code: {result.ExitCode} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
38+
}
39+
40+
protected string[] CaptureMultiple(string binary, params string[] args)
41+
{
42+
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
43+
Exception? e = null;
44+
for (var i = 0; i <= 9; i++)
45+
{
46+
try
47+
{
48+
return CaptureOutput();
49+
}
50+
catch (Exception ex)
51+
{
52+
if (ex is not null)
53+
e = ex;
54+
}
55+
}
56+
57+
if (e is not null)
58+
collector.EmitError("", "failure capturing stdout", e);
59+
60+
return [];
61+
62+
string[] CaptureOutput()
63+
{
64+
var arguments = new StartArguments(binary, args)
65+
{
66+
WorkingDirectory = workingDirectory.FullName,
67+
Timeout = TimeSpan.FromSeconds(3),
68+
WaitForExit = TimeSpan.FromSeconds(3),
69+
ConsoleOutWriter = NoopConsoleWriter.Instance
70+
};
71+
var result = Proc.Start(arguments);
72+
var output = result.ExitCode != 0
73+
? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
74+
: result.ConsoleOut.Select(x => x.Line).ToArray() ?? throw new Exception($"No output captured for {binary}: {workingDirectory}");
75+
return output;
76+
}
77+
}
78+
79+
80+
protected string Capture(string binary, params string[] args) => Capture(false, binary, args);
81+
82+
protected string Capture(bool muteExceptions, string binary, params string[] args)
83+
{
84+
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
85+
Exception? e = null;
86+
for (var i = 0; i <= 9; i++)
87+
{
88+
try
89+
{
90+
return CaptureOutput();
91+
}
92+
catch (Exception ex)
93+
{
94+
if (ex is not null)
95+
e = ex;
96+
}
97+
}
98+
99+
if (e is not null && !muteExceptions)
100+
collector.EmitError("", "failure capturing stdout", e);
101+
102+
return string.Empty;
103+
104+
string CaptureOutput()
105+
{
106+
var arguments = new StartArguments(binary, args)
107+
{
108+
WorkingDirectory = workingDirectory.FullName,
109+
Timeout = TimeSpan.FromSeconds(3),
110+
WaitForExit = TimeSpan.FromSeconds(3),
111+
ConsoleOutWriter = NoopConsoleWriter.Instance
112+
};
113+
var result = Proc.Start(arguments);
114+
var line = (result.ExitCode, muteExceptions) switch
115+
{
116+
(0, _) or (not 0, true) => result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}"),
117+
(not 0, false) => throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
118+
};
119+
return line;
120+
}
121+
}
122+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using ProcNet.Std;
6+
7+
namespace Elastic.Documentation.Tooling.ExternalCommands;
8+
9+
public class NoopConsoleWriter : IConsoleOutWriter
10+
{
11+
public static readonly NoopConsoleWriter Instance = new();
12+
13+
public void Write(Exception e) { }
14+
15+
public void Write(ConsoleOut consoleOut) { }
16+
}

src/tooling/docs-assembler/Sourcing/GitFacade.cs

Lines changed: 17 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
using System.IO.Abstractions;
66
using Elastic.Documentation.Diagnostics;
7-
using ProcNet;
7+
using Elastic.Documentation.Tooling.ExternalCommands;
88

99
namespace Documentation.Assembler.Sourcing;
1010

@@ -24,73 +24,25 @@ public interface IGitRepository
2424

2525
// This git repository implementation is optimized for pull and fetching single commits.
2626
// It uses `git pull --depth 1` and `git fetch --depth 1` to minimize the amount of data transferred.
27-
public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : IGitRepository
27+
public class SingleCommitOptimizedGitRepository(DiagnosticsCollector collector, IDirectoryInfo workingDirectory) : ExternalCommandExecutor(collector, workingDirectory), IGitRepository
2828
{
29-
public string GetCurrentCommit() => Capture("git", "rev-parse", "HEAD");
30-
31-
public void Init() => ExecIn("git", "init");
32-
public bool IsInitialized() => Directory.Exists(Path.Combine(workingDirectory.FullName, ".git"));
33-
public void Pull(string branch) => ExecIn("git", "pull", "--depth", "1", "--allow-unrelated-histories", "--no-ff", "origin", branch);
34-
public void Fetch(string reference) => ExecIn("git", "fetch", "--no-tags", "--prune", "--no-recurse-submodules", "--depth", "1", "origin", reference);
35-
public void EnableSparseCheckout(string folder) => ExecIn("git", "sparse-checkout", "set", folder);
36-
public void DisableSparseCheckout() => ExecIn("git", "sparse-checkout", "disable");
37-
public void Checkout(string reference) => ExecIn("git", "checkout", "--force", reference);
38-
39-
public void GitAddOrigin(string origin) => ExecIn("git", "remote", "add", "origin", origin);
40-
41-
private void ExecIn(string binary, params string[] args)
42-
{
43-
var arguments = new ExecArguments(binary, args)
44-
{
45-
WorkingDirectory = workingDirectory.FullName,
46-
Environment = new Dictionary<string, string>
47-
{
48-
// Disable git editor prompts:
49-
// There are cases where `git pull` would prompt for an editor to write a commit message.
50-
// This env variable prevents that.
51-
{ "GIT_EDITOR", "true" }
52-
},
53-
};
54-
var result = Proc.Exec(arguments);
55-
if (result != 0)
56-
collector.EmitError("", $"Exit code: {result} while executing {binary} {string.Join(" ", args)} in {workingDirectory}");
57-
}
58-
private string Capture(string binary, params string[] args)
29+
private static readonly Dictionary<string, string> EnvironmentVars = new()
5930
{
60-
// Try 10 times to capture the output of the command, if it fails, we'll throw an exception on the last try
61-
Exception? e = null;
62-
for (var i = 0; i <= 9; i++)
63-
{
64-
try
65-
{
66-
return CaptureOutput();
67-
}
68-
catch (Exception ex)
69-
{
70-
if (ex is not null)
71-
e = ex;
72-
}
73-
}
31+
// Disable git editor prompts:
32+
// There are cases where `git pull` would prompt for an editor to write a commit message.
33+
// This env variable prevents that.
34+
{ "GIT_EDITOR", "true" }
35+
};
7436

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

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

80-
string CaptureOutput()
81-
{
82-
var arguments = new StartArguments(binary, args)
83-
{
84-
WorkingDirectory = workingDirectory.FullName,
85-
Timeout = TimeSpan.FromSeconds(3),
86-
WaitForExit = TimeSpan.FromSeconds(3),
87-
ConsoleOutWriter = NoopConsoleWriter.Instance
88-
};
89-
var result = Proc.Start(arguments);
90-
var line = result.ExitCode != 0
91-
? throw new Exception($"Exit code is not 0. Received {result.ExitCode} from {binary}: {workingDirectory}")
92-
: result.ConsoleOut.FirstOrDefault()?.Line ?? throw new Exception($"No output captured for {binary}: {workingDirectory}");
93-
return line;
94-
}
95-
}
47+
public void GitAddOrigin(string origin) => ExecIn(EnvironmentVars, "git", "remote", "add", "origin", origin);
9648
}

src/tooling/docs-assembler/Sourcing/RepositorySourcesFetcher.cs

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,6 @@ private static void FetchAndCheckout(IGitRepository git, Repository repository,
237237
}
238238
}
239239

240-
public class NoopConsoleWriter : IConsoleOutWriter
241-
{
242-
public static readonly NoopConsoleWriter Instance = new();
243-
244-
public void Write(Exception e) { }
245-
246-
public void Write(ConsoleOut consoleOut) { }
247-
}
248-
249240
public record CheckoutResult
250241
{
251242
public static string LinkRegistrySnapshotFileName => "link-index.snapshot.json";
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Licensed to Elasticsearch B.V under one or more agreements.
2+
// Elasticsearch B.V licenses this file to you under the Apache 2.0 License.
3+
// See the LICENSE file in the project root for more information
4+
5+
using System.Diagnostics.CodeAnalysis;
6+
using System.IO.Abstractions;
7+
using Actions.Core.Services;
8+
using ConsoleAppFramework;
9+
using Documentation.Builder.Tracking;
10+
using Elastic.Documentation.Configuration;
11+
using Elastic.Documentation.Configuration.Builder;
12+
using Elastic.Documentation.Tooling.Diagnostics.Console;
13+
using Elastic.Documentation.Tooling.Filters;
14+
using Microsoft.Extensions.Logging;
15+
16+
namespace Documentation.Builder.Cli;
17+
18+
internal sealed class DiffCommands(ILoggerFactory logger, ICoreService githubActionsService)
19+
{
20+
/// <summary>
21+
/// Validates redirect updates in the current branch using the redirects file against changes reported by git.
22+
/// </summary>
23+
/// <param name="path">The baseline path to perform the check</param>
24+
/// <param name="ctx"></param>
25+
[SuppressMessage("Usage", "CA2254:Template should be a static expression")]
26+
[Command("validate")]
27+
[ConsoleAppFilter<StopwatchFilter>]
28+
[ConsoleAppFilter<CatchExceptionFilter>]
29+
public async Task<int> ValidateRedirects([Argument] string? path = null, Cancel ctx = default)
30+
{
31+
var log = logger.CreateLogger<Program>();
32+
ConsoleApp.Log = msg => log.LogInformation(msg);
33+
ConsoleApp.LogError = msg => log.LogError(msg);
34+
35+
path ??= "docs";
36+
37+
await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService).StartAsync(ctx);
38+
39+
var fs = new FileSystem();
40+
var root = fs.DirectoryInfo.New(Paths.WorkingDirectoryRoot.FullName);
41+
42+
var buildContext = new BuildContext(collector, fs, fs, root.FullName, null);
43+
var sourceFile = buildContext.ConfigurationPath;
44+
var redirectFileName = sourceFile.Name.StartsWith('_') ? "_redirects.yml" : "redirects.yml";
45+
var redirectFileInfo = sourceFile.FileSystem.FileInfo.New(Path.Combine(sourceFile.Directory!.FullName, redirectFileName));
46+
47+
var redirectFileParser = new RedirectFile(redirectFileInfo, buildContext);
48+
var redirects = redirectFileParser.Redirects;
49+
50+
if (redirects is null)
51+
{
52+
collector.EmitError(redirectFileInfo, "It was not possible to parse the redirects file.");
53+
await collector.StopAsync(ctx);
54+
return collector.Errors;
55+
}
56+
57+
var tracker = new LocalGitRepositoryTracker(collector, root);
58+
var changed = tracker.GetChangedFiles(path);
59+
60+
foreach (var notFound in changed.DistinctBy(c => c.FilePath).Where(c => c.ChangeType is GitChangeType.Deleted or GitChangeType.Renamed
61+
&& !redirects.ContainsKey(c is RenamedGitChange renamed ? renamed.OldFilePath : c.FilePath)))
62+
{
63+
if (notFound is RenamedGitChange renamed)
64+
{
65+
collector.EmitError(redirectFileInfo.Name,
66+
$"File '{renamed.OldFilePath}' was renamed to '{renamed.NewFilePath}' but it has no redirect configuration set.");
67+
}
68+
else if (notFound.ChangeType is GitChangeType.Deleted)
69+
{
70+
collector.EmitError(redirectFileInfo.Name,
71+
$"File '{notFound.FilePath}' was deleted but it has no redirect targets. This will lead to broken links.");
72+
}
73+
}
74+
75+
await collector.StopAsync(ctx);
76+
return collector.Errors;
77+
}
78+
}

src/tooling/docs-builder/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@
1717
var app = ConsoleApp.Create();
1818
app.Add<Commands>();
1919
app.Add<InboundLinkCommands>("inbound-links");
20+
app.Add<DiffCommands>("diff");
2021

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

0 commit comments

Comments
 (0)