Skip to content

Real redirects support #1397

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 14 commits into from
Jun 27, 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
4 changes: 3 additions & 1 deletion Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
<PackageVersion Include="Amazon.Lambda.S3Events" Version="3.1.0" />
<PackageVersion Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
<PackageVersion Include="Amazon.Lambda.SQSEvents" Version="2.2.0" />
<PackageVersion Include="AWSSDK.CloudFront" Version="4.0.0.10" />
<PackageVersion Include="AWSSDK.CloudFrontKeyValueStore" Version="4.0.0.9" />
<PackageVersion Include="AWSSDK.Core" Version="4.0.0.2" />
<PackageVersion Include="AWSSDK.SQS" Version="4.0.0.1" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.0.1" />
Expand Down Expand Up @@ -70,4 +72,4 @@
</PackageVersion>
<PackageVersion Include="xunit.v3" Version="2.0.2" />
</ItemGroup>
</Project>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 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.Collections.Frozen;
using System.Text.Json.Serialization;
using Elastic.Documentation.Links;
using Elastic.Documentation.Search;
Expand All @@ -18,4 +19,5 @@ namespace Elastic.Documentation.Serialization;
[JsonSerializable(typeof(LinkRegistry))]
[JsonSerializable(typeof(LinkRegistryEntry))]
[JsonSerializable(typeof(DocumentationDocument))]
[JsonSerializable(typeof(Dictionary<string, string>))]
public sealed partial class SourceGenerationContext : JsonSerializerContext;
17 changes: 17 additions & 0 deletions src/tooling/docs-assembler/Building/AssemblerBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
// See the LICENSE file in the project root for more information

using System.Collections.Frozen;
using System.Text.Json;
using Documentation.Assembler.Exporters;
using Documentation.Assembler.Navigation;
using Elastic.Documentation.Legacy;
using Elastic.Documentation.Links;
using Elastic.Documentation.Serialization;
using Elastic.Markdown;
using Elastic.Markdown.Exporters;
using Elastic.Markdown.Links.CrossLinks;
Expand Down Expand Up @@ -90,6 +92,9 @@ public async Task BuildAllAsync(FrozenDictionary<string, AssemblerDocumentationS
_ = await exporter.FinishExportAsync(context.OutputDirectory, ctx);
}

await OutputRedirectsAsync(redirects
.Where(r => !r.Key.TrimEnd('/').Equals(r.Value.TrimEnd('/'), StringComparison.OrdinalIgnoreCase))
.ToDictionary(r => r.Key.TrimEnd('/'), r => r.Value), ctx);

tasks = markdownExporters.Select(async e => await e.StopAsync(ctx));
await Task.WhenAll(tasks);
Expand Down Expand Up @@ -149,4 +154,16 @@ private void SetFeatureFlags(AssemblerDocumentationSet set)
set.DocumentationSet.Configuration.Features.Set(configurationFeatureFlag.Key, configurationFeatureFlag.Value);
}
}

private async Task OutputRedirectsAsync(Dictionary<string, string> redirects, Cancel ctx)
{
var uniqueRedirects = redirects
.Where(x => !x.Key.TrimEnd('/').Equals(x.Value.TrimEnd('/')))
.ToDictionary();
var redirectsFile = context.WriteFileSystem.FileInfo.New(Path.Combine(context.OutputDirectory.FullName, "redirects.json"));
_logger.LogInformation("Writing {Count} resolved redirects to {Path}", uniqueRedirects.Count, redirectsFile.FullName);

var redirectsJson = JsonSerializer.Serialize(uniqueRedirects, SourceGenerationContext.Default.DictionaryStringString);
await context.WriteFileSystem.File.WriteAllTextAsync(redirectsFile.FullName, redirectsJson, ctx);
}
}
120 changes: 120 additions & 0 deletions src/tooling/docs-assembler/Cli/DeployCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@

using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Text.Json;
using Actions.Core.Services;
using Amazon.CloudFront;
using Amazon.CloudFrontKeyValueStore;
using Amazon.CloudFrontKeyValueStore.Model;
using Amazon.S3;
using Amazon.S3.Transfer;
using ConsoleAppFramework;
using Documentation.Assembler.Deploying;
using Elastic.Documentation.Serialization;
using Elastic.Documentation.Tooling.Diagnostics.Console;
using Elastic.Documentation.Tooling.Filters;
using Microsoft.Extensions.Logging;

namespace Documentation.Assembler.Cli;

internal enum KvsOperation
{
Puts,
Deletes
}

internal sealed class DeployCommands(ILoggerFactory logger, ICoreService githubActionsService)
{
[SuppressMessage("Usage", "CA2254:Template should be a static expression")]
Expand Down Expand Up @@ -94,4 +106,112 @@ public async Task<int> Apply(
await collector.StopAsync(ctx);
return collector.Errors;
}

/// <summary>Refreshes the redirects mapping in Cloudfront's KeyValueStore</summary>
/// <param name="environment">The environment to build</param>
/// <param name="redirectsFile">Path to the redirects mapping pre-generated by docs-assembler</param>
/// <param name="ctx"></param>
[Command("update-redirects")]
[ConsoleAppFilter<StopwatchFilter>]
[ConsoleAppFilter<CatchExceptionFilter>]
public async Task<int> UpdateRedirects(
string environment,
string redirectsFile = ".artifacts/assembly/redirects.json",
Cancel ctx = default)
{
AssignOutputLogger();
await using var collector = new ConsoleDiagnosticsCollector(logger, githubActionsService)
{
NoHints = true
}.StartAsync(ctx);

if (!File.Exists(redirectsFile))
{
collector.EmitError(redirectsFile, "Redirects mapping does not exist.");
await collector.StopAsync(ctx);
return collector.Errors;
}

ConsoleApp.Log("Parsing redirects mapping");
var jsonContent = await File.ReadAllTextAsync(redirectsFile, ctx);
var sourcedRedirects = JsonSerializer.Deserialize(jsonContent, SourceGenerationContext.Default.DictionaryStringString);

if (sourcedRedirects is null)
{
collector.EmitError(redirectsFile, "Redirects mapping is invalid.");
await collector.StopAsync(ctx);
return collector.Errors;
}

var kvsName = $"elastic-docs-v3-{environment}-redirects-kvs";

var cfClient = new AmazonCloudFrontClient();
var kvsClient = new AmazonCloudFrontKeyValueStoreClient();

ConsoleApp.Log("Describing KVS");
var describeResponse = await cfClient.DescribeKeyValueStoreAsync(new Amazon.CloudFront.Model.DescribeKeyValueStoreRequest { Name = kvsName }, ctx);

var kvsArn = describeResponse.KeyValueStore.ARN;
var eTag = describeResponse.ETag;
var existingRedirects = new HashSet<string>();

var listKeysRequest = new ListKeysRequest { KvsARN = kvsArn };
ListKeysResponse listKeysResponse;

do
{
listKeysResponse = await kvsClient.ListKeysAsync(listKeysRequest, ctx);
foreach (var item in listKeysResponse.Items)
_ = existingRedirects.Add(item.Key);
listKeysRequest.NextToken = listKeysResponse.NextToken;
}
while (!string.IsNullOrEmpty(listKeysResponse.NextToken));

var toPut = sourcedRedirects
.Select(kvp => new PutKeyRequestListItem { Key = kvp.Key, Value = kvp.Value });
var toDelete = existingRedirects
.Except(sourcedRedirects.Keys)
.Select(k => new DeleteKeyRequestListItem { Key = k });

ConsoleApp.Log("Updating redirects in KVS");
const int batchSize = 50;

eTag = await ProcessBatchUpdatesAsync(kvsClient, kvsArn, eTag, toPut, batchSize, KvsOperation.Puts, ctx);
_ = await ProcessBatchUpdatesAsync(kvsClient, kvsArn, eTag, toDelete, batchSize, KvsOperation.Deletes, ctx);

await collector.StopAsync(ctx);
return collector.Errors;
}

private static async Task<string> ProcessBatchUpdatesAsync(
IAmazonCloudFrontKeyValueStore kvsClient,
string kvsArn,
string eTag,
IEnumerable<object> items,
int batchSize,
KvsOperation operation,
Cancel ctx)
{
var enumerable = items.ToList();
for (var i = 0; i < enumerable.Count; i += batchSize)
{
var batch = enumerable.Skip(i).Take(batchSize);
var updateRequest = new UpdateKeysRequest
{
KvsARN = kvsArn,
IfMatch = eTag
};

if (operation is KvsOperation.Puts)
updateRequest.Puts = batch.Cast<PutKeyRequestListItem>().ToList();
else if (operation is KvsOperation.Deletes)
updateRequest.Deletes = batch.Cast<DeleteKeyRequestListItem>().ToList();

var update = await kvsClient.UpdateKeysAsync(updateRequest, ctx);
eTag = update.ETag;
}

return eTag;
}

}
4 changes: 4 additions & 0 deletions src/tooling/docs-assembler/Cli/RepositoryCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,10 @@ public async Task<int> BuildAll(

await cloner.WriteLinkRegistrySnapshot(checkoutResult.LinkRegistrySnapshot, ctx);

var redirectsPath = Path.Combine(assembleContext.OutputDirectory.FullName, "redirects.json");
if (File.Exists(redirectsPath))
await githubActionsService.SetOutputAsync("redirects-artifact-path", redirectsPath);

var sitemapBuilder = new SitemapBuilder(navigation.NavigationItems, assembleContext.WriteFileSystem, assembleContext.OutputDirectory);
sitemapBuilder.Generate();

Expand Down
2 changes: 2 additions & 0 deletions src/tooling/docs-assembler/docs-assembler.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.CloudFront" />
<PackageReference Include="AWSSDK.CloudFrontKeyValueStore" />
<PackageReference Include="AWSSDK.S3"/>
<PackageReference Include="ConsoleAppFramework.Abstractions"/>
<PackageReference Include="ConsoleAppFramework" />
Expand Down
Loading