Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dbef23f
Move S3 upload to a new integrations project and reuse it for changel…
cotti Mar 31, 2026
41388b3
Update tests/Elastic.Documentation.Integrations.Tests/S3/S3EtagCalcul…
cotti Mar 31, 2026
aac899b
Ne'er forget using
cotti Mar 31, 2026
f386b0d
Merge remote-tracking branch 'origin/changelog-upload-adjustments' in…
cotti Mar 31, 2026
8a935f9
Adjust entities being used
cotti Mar 31, 2026
c52c506
Update src/services/Elastic.Changelog/Uploading/ChangelogUploadServic…
cotti Mar 31, 2026
09931df
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 1, 2026
6795b7e
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 1, 2026
868347b
Add changelog bundle support for hiding private links (#3002)
lcawl Apr 1, 2026
41f0ae6
Fix ApplicationData path collision with workspace root in Docker/CI c…
Mpdreamz Apr 2, 2026
cfd07e2
Fix docs-builder redirect tests (#3008)
lcawl Apr 2, 2026
e41df26
Search: Use default semantic_text inference, remove Jina mappings (#3…
reakaleek Apr 2, 2026
2c30c3a
chore: Update config/versions.yml eck 3.3.2 (#3019)
elastic-observability-automation[bot] Apr 2, 2026
d741c7b
Deploy: Use write-scoped filesystem for apply command (#3021)
reakaleek Apr 2, 2026
dea3e8e
Merge remote-tracking branch 'origin/main' into changelog-upload-adju…
cotti Apr 2, 2026
8f00837
Use ScopedFileSystem and inject interfaces.
cotti Apr 2, 2026
ffb4f71
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 2, 2026
8975d3e
Fix exception filtering
cotti Apr 2, 2026
cac9735
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 2, 2026
4c890f6
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 2, 2026
74b43d0
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 2, 2026
82a2ac5
Merge branch 'main' into changelog-upload-adjustments
cotti Apr 2, 2026
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
2 changes: 2 additions & 0 deletions docs-builder.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
<Project Path="src/services/Elastic.Changelog/Elastic.Changelog.csproj" />
<Project Path="src/services/Elastic.Documentation.Assembler/Elastic.Documentation.Assembler.csproj" />
<Project Path="src/services/Elastic.Documentation.Isolated/Elastic.Documentation.Isolated.csproj" />
<Project Path="src/services/Elastic.Documentation.Integrations/Elastic.Documentation.Integrations.csproj" />
<Project Path="src/services/Elastic.Documentation.Services/Elastic.Documentation.Services.csproj" />
<Project Path="src/services/Elastic.Documentation.Search/Elastic.Documentation.Search.csproj" />
</Folder>
Expand All @@ -99,6 +100,7 @@
<Project Path="tests/Elastic.Markdown.Tests/Elastic.Markdown.Tests.csproj" />
<Project Path="tests/Navigation.Tests/Navigation.Tests.csproj" />
<Project Path="tests/Elastic.Changelog.Tests/Elastic.Changelog.Tests.csproj" />
<Project Path="tests/Elastic.Documentation.Integrations.Tests/Elastic.Documentation.Integrations.Tests.csproj" />
</Folder>
<Project Path=".github/.github.csproj">
<Build Project="false" />
Expand Down
1 change: 1 addition & 0 deletions src/services/Elastic.Changelog/Elastic.Changelog.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
<ProjectReference Include="..\..\Elastic.Documentation\Elastic.Documentation.csproj"/>
<ProjectReference Include="..\..\Elastic.Documentation.Configuration\Elastic.Documentation.Configuration.csproj"/>
<ProjectReference Include="..\Elastic.Documentation.Services\Elastic.Documentation.Services.csproj"/>
<ProjectReference Include="..\Elastic.Documentation.Integrations\Elastic.Documentation.Integrations.csproj"/>
</ItemGroup>

</Project>
169 changes: 169 additions & 0 deletions src/services/Elastic.Changelog/Uploading/ChangelogUploadService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// 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 System.Text.RegularExpressions;
using Amazon.S3;
using Elastic.Changelog.Configuration;
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Changelog;
using Elastic.Documentation.Configuration.ReleaseNotes;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Integrations.S3;
using Elastic.Documentation.ReleaseNotes;
using Elastic.Documentation.Services;
using Microsoft.Extensions.Logging;
using Nullean.ScopedFileSystem;

namespace Elastic.Changelog.Uploading;

public enum ArtifactType { Changelog, Bundle }

public enum UploadTargetKind { S3, Elasticsearch }

public record ChangelogUploadArguments
{
public required ArtifactType ArtifactType { get; init; }
public required UploadTargetKind Target { get; init; }
public required string S3BucketName { get; init; }
public string? Config { get; init; }
public string? Directory { get; init; }
}

public partial class ChangelogUploadService(
ILoggerFactory logFactory,
IConfigurationContext? configurationContext = null,
ScopedFileSystem? fileSystem = null,
IAmazonS3? s3Client = null
) : IService
{
private readonly ILogger _logger = logFactory.CreateLogger<ChangelogUploadService>();
private readonly IFileSystem _fileSystem = fileSystem ?? FileSystemFactory.RealRead;
private readonly ChangelogConfigurationLoader? _configLoader = configurationContext != null
? new ChangelogConfigurationLoader(logFactory, configurationContext, fileSystem ?? FileSystemFactory.RealRead)
: null;

[GeneratedRegex(@"^[a-zA-Z0-9_-]+$")]
private static partial Regex ProductNameRegex();

private static readonly YamlDotNet.Serialization.IDeserializer EntryDeserializer =
ReleaseNotesSerialization.GetEntryDeserializer();

public async Task<bool> Upload(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx)
{
if (args.Target == UploadTargetKind.Elasticsearch)
{
_logger.LogWarning("Elasticsearch upload target is not yet implemented; skipping");
return true;
}

if (args.ArtifactType == ArtifactType.Bundle)
{
_logger.LogWarning("Bundle artifact upload is not yet implemented; skipping");
return true;
}

var changelogDir = await ResolveChangelogDirectory(collector, args, ctx);
if (changelogDir == null)
return false;

if (!_fileSystem.Directory.Exists(changelogDir))
{
_logger.LogInformation("Changelog directory {Directory} does not exist; nothing to upload", changelogDir);
return true;
}

var targets = DiscoverUploadTargets(collector, changelogDir);
if (targets.Count == 0)
{
_logger.LogInformation("No changelog files found to upload in {Directory}", changelogDir);
return true;
}

_logger.LogInformation("Found {Count} upload target(s) from {Directory}", targets.Count, changelogDir);

using var defaultClient = s3Client == null ? new AmazonS3Client() : null;
var client = s3Client ?? defaultClient!;
var etagCalculator = new S3EtagCalculator(logFactory, _fileSystem);
var uploader = new S3IncrementalUploader(logFactory, client, _fileSystem, etagCalculator, args.S3BucketName);
var result = await uploader.Upload(targets, ctx);

_logger.LogInformation("Upload complete: {Uploaded} uploaded, {Skipped} skipped, {Failed} failed", result.Uploaded, result.Skipped, result.Failed);

if (result.Failed > 0)
collector.EmitError(string.Empty, $"{result.Failed} file(s) failed to upload");

return result.Failed == 0;
}

internal IReadOnlyList<UploadTarget> DiscoverUploadTargets(IDiagnosticsCollector collector, string changelogDir)
{
var yamlFiles = _fileSystem.Directory.GetFiles(changelogDir, "*.yaml", SearchOption.TopDirectoryOnly)
.Concat(_fileSystem.Directory.GetFiles(changelogDir, "*.yml", SearchOption.TopDirectoryOnly))
.ToList();

var targets = new List<UploadTarget>();

foreach (var filePath in yamlFiles)
{
var products = ReadProductsFromFragment(filePath);
if (products.Count == 0)
{
_logger.LogDebug("No products found in {File}, skipping", filePath);
continue;
}

var fileName = _fileSystem.Path.GetFileName(filePath);

foreach (var product in products)
{
if (!ProductNameRegex().IsMatch(product))
{
collector.EmitWarning(filePath, $"Skipping invalid product name \"{product}\" (must match [a-zA-Z0-9_-]+)");
continue;
}

var s3Key = $"{product}/changelogs/{fileName}";
targets.Add(new UploadTarget(filePath, s3Key));
}
}

return targets;
}

private List<string> ReadProductsFromFragment(string filePath)
{
try
{
var content = _fileSystem.File.ReadAllText(filePath);
var normalized = ReleaseNotesSerialization.NormalizeYaml(content);
var entry = EntryDeserializer.Deserialize<ChangelogEntryDto>(normalized);
if (entry?.Products == null)
return [];

return entry.Products
.Select(p => p?.Product)
.Where(p => !string.IsNullOrWhiteSpace(p))
.Select(p => p!)
.ToList();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not read products from {File}", filePath);
return [];
}
}

private async Task<string?> ResolveChangelogDirectory(IDiagnosticsCollector collector, ChangelogUploadArguments args, Cancel ctx)
{
if (!string.IsNullOrWhiteSpace(args.Directory))
return args.Directory;

if (_configLoader == null)
return "docs/changelog";

var config = await _configLoader.LoadChangelogConfiguration(collector, args.Config, ctx);
return config?.Bundle?.Directory ?? "docs/changelog";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using Elastic.Documentation.Configuration;
using Elastic.Documentation.Configuration.Assembler;
using Elastic.Documentation.Diagnostics;
using Elastic.Documentation.Integrations.S3;
using Elastic.Documentation.Services;
using Microsoft.Extensions.Logging;
using Nullean.ScopedFileSystem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,76 +3,13 @@
// See the LICENSE file in the project root for more information

using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography;
using Amazon.S3;
using Amazon.S3.Model;
using Elastic.Documentation.Integrations.S3;
using Microsoft.Extensions.Logging;

namespace Elastic.Documentation.Assembler.Deploying.Synchronization;

public interface IS3EtagCalculator
{
Task<string> CalculateS3ETag(string filePath, Cancel ctx = default);
}

public class S3EtagCalculator(ILoggerFactory logFactory, IFileSystem readFileSystem) : IS3EtagCalculator
{
private readonly ILogger<AwsS3SyncPlanStrategy> _logger = logFactory.CreateLogger<AwsS3SyncPlanStrategy>();

private static readonly ConcurrentDictionary<string, string> EtagCache = new();

internal const long PartSize = 5 * 1024 * 1024; // 5MB

[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public async Task<string> CalculateS3ETag(string filePath, Cancel ctx = default)
{
if (EtagCache.TryGetValue(filePath, out var cachedEtag))
{
_logger.LogDebug("Using cached ETag for {Path}", filePath);
return cachedEtag;
}

var fileInfo = readFileSystem.FileInfo.New(filePath);
var fileSize = fileInfo.Length;

// For files under 5MB, use simple MD5 (matching TransferUtility behavior)
if (fileSize <= PartSize)
{
await using var stream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var smallBuffer = new byte[fileSize];
var bytesRead = await stream.ReadAsync(smallBuffer.AsMemory(0, (int)fileSize), ctx);
var hash = MD5.HashData(smallBuffer.AsSpan(0, bytesRead));
var etag = Convert.ToHexStringLower(hash);
EtagCache[filePath] = etag;
return etag;
}

// For files over 5MB, use multipart format with 5MB parts (matching TransferUtility)
var parts = (int)Math.Ceiling((double)fileSize / PartSize);

await using var fileStream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var partBuffer = new byte[PartSize];
var partHashes = new List<byte[]>();

for (var i = 0; i < parts; i++)
{
var bytesRead = await fileStream.ReadAsync(partBuffer.AsMemory(0, partBuffer.Length), ctx);
var partHash = MD5.HashData(partBuffer.AsSpan(0, bytesRead));
partHashes.Add(partHash);
}

// Concatenate all part hashes
var concatenatedHashes = partHashes.SelectMany(h => h).ToArray();
var finalHash = MD5.HashData(concatenatedHashes);

var multipartEtag = $"{Convert.ToHexStringLower(finalHash)}-{parts}";
EtagCache[filePath] = multipartEtag;
return multipartEtag;
}
}

public class AwsS3SyncPlanStrategy(
ILoggerFactory logFactory,
IAmazonS3 s3Client,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<ProjectReference Include="..\..\Elastic.Documentation.Links\Elastic.Documentation.Links.csproj" />
<ProjectReference Include="..\..\Elastic.Markdown\Elastic.Markdown.csproj" />
<ProjectReference Include="..\Elastic.Documentation.Services\Elastic.Documentation.Services.csproj" />
<ProjectReference Include="..\Elastic.Documentation.Integrations\Elastic.Documentation.Integrations.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.S3" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// 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.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.Security.Cryptography;
using Microsoft.Extensions.Logging;

namespace Elastic.Documentation.Integrations.S3;

public interface IS3EtagCalculator
{
Task<string> CalculateS3ETag(string filePath, Cancel ctx = default);
}

public class S3EtagCalculator(ILoggerFactory logFactory, IFileSystem readFileSystem) : IS3EtagCalculator
{
private readonly ILogger _logger = logFactory.CreateLogger<S3EtagCalculator>();

private readonly ConcurrentDictionary<string, string> _etagCache = new();

public const long PartSize = 5 * 1024 * 1024; // 5MB — matches TransferUtility default

[SuppressMessage("Security", "CA5351:Do Not Use Broken Cryptographic Algorithms")]
public async Task<string> CalculateS3ETag(string filePath, Cancel ctx = default)
{
if (_etagCache.TryGetValue(filePath, out var cachedEtag))
{
_logger.LogDebug("Using cached ETag for {Path}", filePath);
return cachedEtag;
}

var fileInfo = readFileSystem.FileInfo.New(filePath);
var fileSize = fileInfo.Length;

if (fileSize <= PartSize)
{
await using var stream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var smallBuffer = new byte[fileSize];
var bytesRead = await stream.ReadAsync(smallBuffer.AsMemory(0, (int)fileSize), ctx);
var hash = MD5.HashData(smallBuffer.AsSpan(0, bytesRead));
var etag = Convert.ToHexStringLower(hash);
_etagCache[filePath] = etag;
return etag;
}

var parts = (int)Math.Ceiling((double)fileSize / PartSize);
await using var fileStream = readFileSystem.FileStream.New(filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
var partBuffer = new byte[PartSize];
var partHashes = new List<byte[]>();

for (var i = 0; i < parts; i++)
{
var bytesRead = await fileStream.ReadAsync(partBuffer.AsMemory(0, partBuffer.Length), ctx);
var partHash = MD5.HashData(partBuffer.AsSpan(0, bytesRead));
partHashes.Add(partHash);
}

var concatenatedHashes = partHashes.SelectMany(h => h).ToArray();
var finalHash = MD5.HashData(concatenatedHashes);
var multipartEtag = $"{Convert.ToHexStringLower(finalHash)}-{parts}";
_etagCache[filePath] = multipartEtag;
return multipartEtag;
}
}
Loading
Loading