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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,8 @@ flowchart LR

api -- http:8088/hg --- hgweb([hgweb])
hgweb -- /var/hg/repos --- repos
api -- /hg-repos --- repos

api -- http:80 --- hgresumable([hgresumable])
api -- "http:8080 (resumable + /api/manage)" --- hgresumable([hgresumable])
hgresumable -- /var/vcs/public --- repos
hgresumable -- hgresumable-cache --- cache[//var/cache/hgresume/]

Expand Down
3 changes: 0 additions & 3 deletions backend/LexBoxApi/LexBoxApi.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,4 @@
<ProjectReference Include="..\LfClassicData\LfClassicData.csproj" />
<ProjectReference Include="..\SyncReverseProxy\SyncReverseProxy.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="Services\HgEmptyRepo\**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
3 changes: 2 additions & 1 deletion backend/LexBoxApi/LexBoxKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,9 @@ public static void AddLexBoxApi(this IServiceCollection services,
services.AddScoped<TusService>();
services.AddScoped<TurnstileService>();
services.AddScoped<IHgService, HgService>();
// Uploading a reset zip or copying a large repo can far outlast HttpClient's 100s default
services.AddHttpClient(HgService.ManageClientName, client => client.Timeout = TimeSpan.FromMinutes(30));
services.AddSingleton<FwLiteReleaseService>();
services.AddHostedService<HgService>();
services.AddTransient<HgWebHealthCheck>();
services.AddTransient<FwHeadlessHealthCheck>();
services.AddScoped<ILexProxyService, LexProxyService>();
Expand Down
Binary file not shown.
1 change: 0 additions & 1 deletion backend/LexBoxApi/Services/HgEmptyRepo/.hg/requires

This file was deleted.

7 changes: 0 additions & 7 deletions backend/LexBoxApi/Services/HgEmptyRepo/.hg/store/requires

This file was deleted.

312 changes: 83 additions & 229 deletions backend/LexBoxApi/Services/HgService.cs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ public void InvalidateProjectCodeCache(string projectCode)
var exists = await dbContext.Projects.Where(p => p.Code == code)
.AnyAsync();
if (!exists) return null;
var backupExecutor = hgService.BackupRepo(code);
var backupExecutor = await hgService.BackupRepo(code);
return backupExecutor;
}

Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/appsettings.Development.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"FwHeadlessUrl": "http://localhost:5275/"
},
"HgConfig": {
"RepoPath": "../../hgweb/repos",
"HgResumeManageSecret": "9c1f0e7a4b2d4e6f8a3b5c7d9e1f2a4b",
"HgWebUrl": "http://localhost:8088/hg/",
"HgCommandServer": "http://localhost:8088/command/",
"HgResumableUrl": "http://localhost:8034/",
Expand Down
2 changes: 1 addition & 1 deletion backend/LexBoxApi/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
"TurnstileKey": null
},
"HgConfig": {
"RepoPath": null
"HgResumeManageSecret": null
},
"Authentication": {
"Jwt": {
Expand Down
7 changes: 5 additions & 2 deletions backend/LexCore/Config/HgConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ namespace LexCore.Config;

public class HgConfig
{
[Required]
public required string RepoPath { get; init; }
[Required]
public required string SendReceiveDomain { get; init; }
[Required, Url, RegularExpression(@"^.+/$", ErrorMessage = "Must end with '/'")]
Expand All @@ -15,6 +13,11 @@ public class HgConfig
public required string HgCommandServer { get; init; }
[Required, Url]
public required string HgResumableUrl { get; init; }
/// <summary>
/// Sent as X-Manage-Secret to hgresume's /api/manage, which does every repo filesystem operation for lexbox
/// </summary>
[Required]
public required string HgResumeManageSecret { get; init; }
public bool AutoUpdateLexEntryCountOnSendReceive { get; init; } = false;
public bool RequireContainerVersionMatch { get; init; } = true;
public int ResetCleanupAgeDays { get; init; } = 31;
Expand Down
4 changes: 2 additions & 2 deletions backend/LexCore/Entities/Project.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@
}
}

public bool GetHasAbandonedTransactions(IHgService hgService)
public async Task<bool> GetHasAbandonedTransactions(IHgService hgService)

Check warning on line 56 in backend/LexCore/Entities/Project.cs

View workflow job for this annotation

GitHub Actions / .NET build and tests (affected)

This method can return the Task directly instead of using async and await (https://microsoft.github.io/vs-threading/analyzers/VSTHRD202.html)

Check warning on line 56 in backend/LexCore/Entities/Project.cs

View workflow job for this annotation

GitHub Actions / Build API / publish-api

This method can return the Task directly instead of using async and await (https://microsoft.github.io/vs-threading/analyzers/VSTHRD202.html)

Check warning on line 56 in backend/LexCore/Entities/Project.cs

View workflow job for this annotation

GitHub Actions / GHA integration tests / dotnet

This method can return the Task directly instead of using async and await (https://microsoft.github.io/vs-threading/analyzers/VSTHRD202.html)
{
return hgService.HasAbandonedTransactions(Code);
return await hgService.HasAbandonedTransactions(Code);
}

public Task<bool> GetIsLanguageForgeProject(IIsLanguageForgeProjectDataLoader loader)
Expand Down
4 changes: 2 additions & 2 deletions backend/LexCore/ServiceInterfaces/IHgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public interface IHgService
Task<ProjectWritingSystems?> GetProjectWritingSystems(ProjectCode code, CancellationToken token = default);
Task<Guid?> GetProjectIdOfFlexProject(ProjectCode code, CancellationToken token = default);
Task<int?> GetModelVersionOfFlexProject(ProjectCode code, CancellationToken token = default);
BackupExecutor? BackupRepo(ProjectCode code);
Task<BackupExecutor?> BackupRepo(ProjectCode code);
Task ResetRepo(ProjectCode code);
Task FinishReset(ProjectCode code, Stream zipFile);
Task<HttpContent> VerifyRepo(ProjectCode code, CancellationToken token);
Expand All @@ -28,7 +28,7 @@ public interface IHgService
Task<ZipArchive?> GetLdmlZip(ProjectCode code, CancellationToken token = default);
Task<HttpContent> ExecuteHgRecover(ProjectCode code, CancellationToken token);
Task<HttpContent?> InvalidateDirCache(ProjectCode code, CancellationToken token = default);
bool HasAbandonedTransactions(ProjectCode projectCode);
Task<bool> HasAbandonedTransactions(ProjectCode projectCode);
Task<string> HgCommandHealth();

Task<string[]> CleanupResetBackups(bool dryRun = false);
Expand Down
2 changes: 1 addition & 1 deletion backend/SyncReverseProxy/ProxyKernel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ private static async Task Forward(HttpContext context,
break;
}

if (hgService.HasAbandonedTransactions(projectCode))
if (await hgService.HasAbandonedTransactions(projectCode))
Activity.Current?.AddTag("app.abandoned_transaction_detected", true);
}
catch (Exception e)
Expand Down
130 changes: 47 additions & 83 deletions backend/Testing/LexCore/Services/HgServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System.IO.Compression;
using System.Net;
using LexBoxApi.Services;
using LexCore.Config;
using LexCore.Exceptions;
Expand All @@ -15,40 +15,29 @@ public class HgServiceTests
{
private const string LexboxHgWeb = "https://hg.lexbox";
private const string LexboxResumable = "https://resumable.lexbox";
private readonly string _basePath = Path.Join(Path.GetTempPath(), "HgServiceTests");
private const string ManageSecret = "test-manage-secret";
private readonly HgConfig _hgConfig;
private readonly Mock<HttpMessageHandler> _handler = new(MockBehavior.Strict);
private readonly HgService _hgService;

public HgServiceTests()
{
_hgConfig = new HgConfig
{
RepoPath = Path.Join(_basePath, "hg-repos"),
HgWebUrl = LexboxHgWeb,
HgCommandServer = LexboxHgWeb + "/command/",
HgResumableUrl = LexboxResumable,
HgResumeManageSecret = ManageSecret,
SendReceiveDomain = LexboxHgWeb
};
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);

// This may need to become more sophisticated if our FinishReset tests are changed to include
// a Mercurial repo with actual commits in it, but this is good enough at the moment.
// Default for the command server's tip lookups that follow repo changes; tests set up the manage calls they expect.
var AllZeroHash = "0000000000000000000000000000000000000000";
handler.SetupAnyRequest().ReturnsResponse(AllZeroHash);
_handler.SetupAnyRequest().ReturnsResponse(AllZeroHash);

var mockFactory = handler.CreateClientFactory();
_hgService = new HgService(new OptionsWrapper<HgConfig>(_hgConfig),
mockFactory,
_handler.CreateClientFactory(),
NullLogger<HgService>.Instance);
CleanUpTempDir();
}

private void CleanUpTempDir()
{
if (Directory.Exists(_basePath))
{
Directory.Delete(_basePath, true);
}
}

[Theory]
Expand Down Expand Up @@ -83,87 +72,62 @@ public void HgDatesConvertedAccurately(string? input, string? expectedStr)
actual.Should().Be(expected);
}

[Theory]
[InlineData(".hg/important-file.bin")]
[InlineData("unzip-test/.hg/important-file.bin")]
public async Task CanFinishResetByUnZippingAnArchive(string filePath)
[Fact]
public async Task InitRepoCallsManageApiWithSecret()
{
// arrange
var code = "unzip-test";
await _hgService.InitRepo(code);
_handler.SetupRequest(HttpMethod.Post, LexboxResumable + "/api/manage/repos/init-test",
r => r.Headers.GetValues("X-Manage-Secret").Single() == ManageSecret)
.ReturnsResponse(HttpStatusCode.Created);

// act
using var stream = new MemoryStream();
using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
CreateSimpleEntry(zipArchive, filePath);
CreateSimpleEntry(zipArchive, "random-subfolder/other-file.txt");
}
stream.Position = 0;
await _hgService.FinishReset(code, stream);

// assert
var repoPath = Path.GetFullPath(Path.Join(_hgConfig.RepoPath, "u", code));
Directory.EnumerateFiles(repoPath, "*", SearchOption.AllDirectories)
.Select(p => Path.GetRelativePath(repoPath, p))
.Should().ContainSingle().Which.Should().Be(Path.Join(".hg", "important-file.bin"));
await _hgService.InitRepo("init-test");

_handler.VerifyRequest(HttpMethod.Post, LexboxResumable + "/api/manage/repos/init-test", Times.Once());
}

[Theory]
[InlineData("")]
[InlineData("unzip-test/")]
public async Task FinishResetDropsUploadedHgrc(string prefix)
[Fact]
public async Task InitRepoThrowsWhenRepoAlreadyExists()
{
var code = "unzip-test-hgrc";
await _hgService.InitRepo(code);
_handler.SetupRequest(HttpMethod.Post, LexboxResumable + "/api/manage/repos/init-test")
.ReturnsResponse(HttpStatusCode.Conflict);

using var stream = new MemoryStream();
using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
CreateSimpleEntry(zipArchive, prefix + ".hg/hgrc");
CreateSimpleEntry(zipArchive, prefix + ".hg/important-file.bin");
}
stream.Position = 0;
await _hgService.FinishReset(code, stream);

var repoPath = Path.GetFullPath(Path.Join(_hgConfig.RepoPath, "u", code));
Directory.EnumerateFiles(repoPath, "*", SearchOption.AllDirectories)
.Select(p => Path.GetRelativePath(repoPath, p))
.Should().ContainSingle().Which.Should().Be(Path.Join(".hg", "important-file.bin"));
var act = () => _hgService.InitRepo("init-test");
await act.Should().ThrowAsync<AlreadyExistsException>();
}

[Theory]
[InlineData("-xy")]
[InlineData("-x-y-z")]
[InlineData("-123")]
private async Task ProjectCodesMayNotStartWithHyphen(string code)
[Fact]
public async Task FinishResetMapsMissingHgFolderProblem()
{
await Assert.ThrowsAsync<ArgumentException>(() => _hgService.InitRepo(code));
_handler.SetupRequest(HttpMethod.Post, LexboxResumable + "/api/manage/repos/unzip-test-no-hg/finish-reset")
.ReturnsJsonResponse(HttpStatusCode.BadRequest, new { status = 400, title = "ZIP_MISSING_HG_FOLDER" });

var act = () => _hgService.FinishReset("unzip-test-no-hg", new MemoryStream());
await act.Should().ThrowAsync<ProjectResetException>();
}

private void CreateSimpleEntry(ZipArchive zipArchive, string filePath)
[Fact]
public async Task HasAbandonedTransactionsIsFalseForMissingRepo()
{
var entry = zipArchive.CreateEntry(filePath);
using var fileStream = entry.Open();
Span<byte> buff = stackalloc byte[100];
Random.Shared.NextBytes(buff);
fileStream.Write(buff);
fileStream.Flush();
_handler.SetupRequest(HttpMethod.Get, LexboxResumable + "/api/manage/repos/missing")
.ReturnsResponse(HttpStatusCode.NotFound);

(await _hgService.HasAbandonedTransactions("missing")).Should().BeFalse();
}

[Fact]
public async Task ThrowsIfNoHgFolderIsFound()
public async Task HasAbandonedTransactionsReadsRepoStatus()
{
var code = "unzip-test-no-hg";
await _hgService.InitRepo(code);
using var stream = new MemoryStream();
using (var zipArchive = new ZipArchive(stream, ZipArchiveMode.Create, true))
{
CreateSimpleEntry(zipArchive, "random-subfolder/other-file.txt");
}
_handler.SetupRequest(HttpMethod.Get, LexboxResumable + "/api/manage/repos/journal")
.ReturnsJsonResponse(new { hasAbandonedTransactions = true, isLocked = false });

stream.Position = 0;
var act = () => _hgService.FinishReset(code, stream);
await act.Should().ThrowAsync<ProjectResetException>();
(await _hgService.HasAbandonedTransactions("journal")).Should().BeTrue();
}

[Theory]
[InlineData("-xy")]
[InlineData("-x-y-z")]
[InlineData("-123")]
private async Task ProjectCodesMayNotStartWithHyphen(string code)
{
await Assert.ThrowsAsync<ArgumentException>(() => _hgService.InitRepo(code));
}
}
23 changes: 22 additions & 1 deletion deployment/base/hg-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,27 @@ spec:
runAsUser: 33
runAsNonRoot: true

initContainers:
# hgresume creates the repo layout at startup, as www-data, so it needs to own the volume root
- name: set-repo-structure-owner-to-www-data
securityContext:
# Make sure we're authorized to set ownership
runAsUser: 0
runAsGroup: 0
runAsNonRoot: false
image: busybox:1.36.1
command:
- 'sh'
- '-c'
- |
cd /repos
chown www-data:www-data .
# Only necessary if directories already exist with the wrong ownership
find . -maxdepth 1 -type d ! -name lost+found -exec chown www-data:www-data {} +
volumeMounts:
- name: repos
mountPath: /repos

containers:
- name: hgweb
image: ghcr.io/sillsdev/lexbox-hgweb:latest
Expand Down Expand Up @@ -158,7 +179,7 @@ spec:


- name: hgresumable
image: ghcr.io/sillsdev/hgresume:v2026-09-30
image: ghcr.io/sillsdev/hgresume:v2026-10-28
# https://kubernetes.io/docs/concepts/configuration/manage-resources-containers
resources:
requests:
Expand Down
Loading
Loading