Skip to content
Open
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
12 changes: 12 additions & 0 deletions .github/workflows/docs-preview-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,18 @@ jobs:
--distribution-id EKT7LT5PM8RKS \
--paths "${PATH_PREFIX}" "${PATH_PREFIX}/*"

- name: Deploy redirects to CloudFront KVS
if: >
env.MATCH == 'true'
&& !cancelled()
&& needs.check.outputs.any_modified != 'false'
&& github.event_name == 'push'
&& steps.s3-upload.outcome == 'success'
run: |
if [ -f .artifacts/docs/html/redirects.json ]; then
dotnet run --project src/tooling/docs-builder -- assembler deploy update-redirects --no-delete preview --redirectsFile .artifacts/docs/html/redirects.json
fi

- name: Update Link Index
if: >
env.MATCH == 'true'
Expand Down
8 changes: 8 additions & 0 deletions docs/cli-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2596,6 +2596,14 @@
}
]
},
{
"role": "flag",
"name": "no-delete",
"type": "boolean",
"required": false,
"summary": "Only PUT the new entries without deleting any existing KVS keys.\nUse for per-docset isolated builds that should not remove other repos\u0027 redirects.",
"defaultValue": "false"
},
{
"role": "flag",
"name": "log-level",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ public async Task<bool> UpdateRedirects(
string? redirectsFile,
string kvsNamePrefix = "elastic-docs-v3",
string? defaultRedirectsFile = null,
bool noDelete = false,
Cancel ctx = default)
{
redirectsFile ??= defaultRedirectsFile ?? ".artifacts/assembly/redirects.json";
Expand All @@ -44,7 +45,7 @@ public async Task<bool> UpdateRedirects(
var kvsName = $"{kvsNamePrefix}-{environment}-redirects-kvs";
var cloudFrontClient = new AwsCloudFrontKeyValueStoreProxy(collector, logFactory, fileSystem.DirectoryInfo.New(fileSystem.Directory.GetCurrentDirectory()));

cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects);
cloudFrontClient.UpdateRedirects(kvsName, sourcedRedirects, noDelete);
return collector.Errors == 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,11 @@ public class AwsCloudFrontKeyValueStoreProxy(IDiagnosticsCollector collector, IL
/// <inheritdoc />
protected override ILogger Logger { get; } = logFactory.CreateLogger<AwsCloudFrontKeyValueStoreProxy>();

public void UpdateRedirects(string kvsName, IReadOnlyDictionary<string, string> sourcedRedirects)
/// <param name="noDelete">
/// When <c>true</c>, only PUT the new entries without deleting any existing KVS keys.
/// Use this for partial (isolated / per-docset) deploys that should not touch other repos' redirects.
/// </param>
public void UpdateRedirects(string kvsName, IReadOnlyDictionary<string, string> sourcedRedirects, bool noDelete = false)
{
var kvsArn = DescribeKeyValueStore(kvsName);
if (string.IsNullOrEmpty(kvsArn))
Expand All @@ -32,25 +36,39 @@ public void UpdateRedirects(string kvsName, IReadOnlyDictionary<string, string>
if (string.IsNullOrEmpty(eTag))
return;

var listingSuccessful = TryListAllKeys(kvsArn, out var existingRedirects);
PutKeyRequestListItem[] toPut;
DeleteKeyRequestListItem[] toDelete;

if (!listingSuccessful)
return;

if (RedirectKvsDiff.WouldWipeAllExisting(sourcedRedirects, existingRedirects))
if (noDelete)
{
Collector.EmitError("", $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " +
"This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying.");
return;
toPut = sourcedRedirects
.Select(kvp => new PutKeyRequestListItem { Key = kvp.Key, Value = kvp.Value })
.ToArray();
toDelete = [];
}
else
{
var listingSuccessful = TryListAllKeys(kvsArn, out var existingRedirects);
if (!listingSuccessful)
return;

var (toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects);
if (RedirectKvsDiff.WouldWipeAllExisting(sourcedRedirects, existingRedirects))
{
Collector.EmitError("", $"Refusing to update redirects: sourced redirects are empty but the KVS contains {existingRedirects.Count} entries. " +
"This would wipe every redirect. Verify the assembler produced a non-empty redirects.json before retrying.");
return;
}

(toPut, toDelete) = RedirectKvsDiff.ComputeBatchUpdates(sourcedRedirects, existingRedirects);
}

Logger.LogInformation("Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (from {Existing} existing, {Sourced} sourced)",
toPut.Length, toDelete.Length, existingRedirects.Count, sourcedRedirects.Count);
Logger.LogInformation("Computed redirect KVS diff: {ToPut} to put, {ToDelete} to delete (noDelete={NoDelete})",
toPut.Length, toDelete.Length, noDelete);

eTag = ProcessBatchUpdates(kvsArn, eTag, toDelete, KvsOperation.Deletes);
_ = ProcessBatchUpdates(kvsArn, eTag, toPut, KvsOperation.Puts);
if (toDelete.Length > 0)
eTag = ProcessBatchUpdates(kvsArn, eTag, toDelete, KvsOperation.Deletes);
if (toPut.Length > 0)
_ = ProcessBatchUpdates(kvsArn, eTag, toPut, KvsOperation.Puts);
}

private string DescribeKeyValueStore(string kvsName)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591;CS1573;CS1572;CS1571;CS1570;CS1574</NoWarn>
</PropertyGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Elastic.Documentation.Build.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="GitHub.Actions.Core" />
<PackageReference Include="Nullean.Argh.Interfaces" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information

using System.IO.Abstractions;
using System.Text.Json;
using Actions.Core.Services;
using Elastic.ApiExplorer;
using Elastic.Documentation;
Expand All @@ -15,6 +16,7 @@
using Elastic.Documentation.Links;
using Elastic.Documentation.Links.CrossLinks;
using Elastic.Documentation.Navigation;
using Elastic.Documentation.Serialization;
using Elastic.Documentation.Services;
using Elastic.Documentation.Site.Navigation;
using Elastic.Markdown;
Expand Down Expand Up @@ -164,7 +166,7 @@ public async Task<bool> Build(


var generator = new DocumentationGenerator(set, logFactory, set, null, null, markdownExporters.ToArray(), documentInferrer: documentInferrer);
_ = await generator.GenerateAll(ctx);
var result = await generator.GenerateAll(ctx);

if (!skipOpenApi)
{
Expand All @@ -175,6 +177,9 @@ public async Task<bool> Build(
if (runningOnCi)
await githubActionsService.SetOutputAsync("landing-page-path", set.FirstInterestingUrl);

if (result.Redirects.Count > 0)
await WriteRedirectsAsync(result.Redirects, context, ctx);

var finishTasks = markdownExporters.Select(async e => await e.FinishExportAsync(context.OutputDirectory, ctx));
_ = await Task.WhenAll(finishTasks);

Expand All @@ -185,6 +190,61 @@ public async Task<bool> Build(
return strict.Value ? context.Collector.Errors + context.Collector.Warnings == 0 : context.Collector.Errors == 0;
}

private async Task WriteRedirectsAsync(IReadOnlyDictionary<string, LinkRedirect> redirects, BuildContext context, Cancel ctx)
{
var pathPrefix = (context.UrlPathPrefix ?? string.Empty).TrimEnd('/');
var resolved = new Dictionary<string, string>();

foreach (var (from, redirect) in redirects)
{
string? to = null;
if (redirect.To is not null)
to = redirect.To;
else if (redirect.Many is { Length: > 0 })
to = redirect.Many.FirstOrDefault(r => r.To is not null)?.To;

if (to is null || to.Contains("://"))
continue;

var fromUrl = ToAbsoluteUrl(from, pathPrefix);
var toUrl = ToAbsoluteUrl(to, pathPrefix);

if (!string.IsNullOrEmpty(fromUrl) && !string.IsNullOrEmpty(toUrl)
&& !fromUrl.TrimEnd('/').Equals(toUrl.TrimEnd('/'), OrdinalIgnoreCase))
{
resolved[fromUrl] = toUrl;
}
}

if (resolved.Count == 0)
return;

var redirectsFile = context.WriteFileSystem.FileInfo.New(
Path.Join(context.OutputDirectory.FullName, "redirects.json"));
_logger.LogInformation("Writing {Count} resolved redirects to {Path}", resolved.Count, redirectsFile.FullName);

var json = JsonSerializer.Serialize(resolved, SourceGenerationContext.Default.DictionaryStringString);
await context.WriteFileSystem.File.WriteAllTextAsync(redirectsFile.FullName, json, ctx);
}

internal static string ToAbsoluteUrl(string path, string pathPrefix)
{
pathPrefix = pathPrefix.TrimEnd('/');

if (path.EndsWith(".md", OrdinalIgnoreCase))
path = path[..^3];

if (path.EndsWith("/index", OrdinalIgnoreCase))
path = path[..^6];
else if (path.Equals("index", OrdinalIgnoreCase))
return string.IsNullOrEmpty(pathPrefix) ? "/" : pathPrefix;

if (string.IsNullOrEmpty(path))
return string.IsNullOrEmpty(pathPrefix) ? "/" : pathPrefix;

return $"{pathPrefix}/{path}";
}

/// <summary>
/// Builds a pre-configured documentation set with optional injected navigation.
/// Used by portal builds where navigation spans multiple documentation sets.
Expand Down
10 changes: 7 additions & 3 deletions src/tooling/docs-builder/Commands/Assembler/DeployCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,19 @@ static async (s, collector, state, ctx) => await s.Apply(collector, state.contex
/// <remarks>Run after <c>assembler build</c> produces a <c>redirects.json</c>.</remarks>
/// <param name="environment">Named deployment target.</param>
/// <param name="redirectsFile">Path to <c>redirects.json</c>. Defaults to <c>.artifacts/docs/redirects.json</c>.</param>
/// <param name="noDelete">
/// Only PUT the new entries without deleting any existing KVS keys.
/// Use for per-docset isolated builds that should not remove other repos' redirects.
/// </param>
[NoOptionsInjection]
public async Task<int> UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, CancellationToken ct = default)
public async Task<int> UpdateRedirects(string environment, [Existing, ExpandUserProfile, RejectSymbolicLinks, FileExtensions(Extensions = "json")] FileInfo? redirectsFile = null, bool noDelete = false, CancellationToken ct = default)
{
await using var serviceInvoker = new ServiceInvoker(collector);

var fs = FileSystemFactory.RealRead;
var service = new DeployUpdateRedirectsService(logFactory, fs);
serviceInvoker.AddCommand(service, (environment, redirectsFile),
static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, ctx: ctx)
serviceInvoker.AddCommand(service, (environment, redirectsFile, noDelete),
static async (s, collector, state, ctx) => await s.UpdateRedirects(collector, state.environment, state.redirectsFile?.FullName, noDelete: state.noDelete, ctx: ctx)
);
return await serviceInvoker.InvokeAsync(ct);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public async Task<int> UpdateRedirects(

var service = new DeployUpdateRedirectsService(logFactory, readFs);
serviceInvoker.AddCommand(service, (environment: resolvedEnvironment, redirectsFile, kvsNamePrefix: "codex", defaultRedirectsFile: ".artifacts/codex/docs/redirects.json"),
static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, c)
static async (s, col, state, c) => await s.UpdateRedirects(col, state.environment, state.redirectsFile?.FullName, state.kvsNamePrefix, state.defaultRedirectsFile, ctx: c)
);
return await serviceInvoker.InvokeAsync(ct);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// 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 AwesomeAssertions;
using Elastic.Documentation.Isolated;

namespace Elastic.Documentation.Build.Tests;

public class IsolatedBuildRedirectTests
{
[Theory]
[InlineData("migration/freeze/gh-action.md", "/en/docs-builder", "/en/docs-builder/migration/freeze/gh-action")]
[InlineData("schema-support/cli-schema/index.md", "/en/docs-builder", "/en/docs-builder/schema-support/cli-schema")]
[InlineData("index.md", "/en/docs-builder", "/en/docs-builder")]
[InlineData("migration/freeze/index.md", "/en/docs-builder", "/en/docs-builder/migration/freeze")]
[InlineData("cli/installation.md", "/en/docs-builder", "/en/docs-builder/cli/installation")]
[InlineData("index.md", "", "/")]
[InlineData("index.md", "/", "/")]
[InlineData("migrate/index.md", "", "/migrate")]
[InlineData("migrate/index.md", "/", "/migrate")]
public void ToAbsoluteUrl_VariousPaths_ProducesExpectedUrl(string path, string pathPrefix, string expected) =>
IsolatedBuildService.ToAbsoluteUrl(path, pathPrefix).Should().Be(expected);

[Theory]
[InlineData("migration/freeze/gh-action.md", "migration/freeze/gh-action.md")]
[InlineData("cli/installation.md", "cli/installation.md")]
public void ToAbsoluteUrl_FromAndToEquivalent_SelfRedirectDetected(string path, string to)
{
var prefix = "/en/docs-builder";
IsolatedBuildService.ToAbsoluteUrl(path, prefix)
.TrimEnd('/')
.Should().Be(IsolatedBuildService.ToAbsoluteUrl(to, prefix).TrimEnd('/'));
}
}
Loading