Skip to content
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
67 changes: 60 additions & 7 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,56 @@ on:
push:
branches:
- dockerize
- claude/csharp-rewrite
pull_request:

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

jobs:
test:
name: Build image and run HTTP-level tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build image for tests (amd64, loaded into docker)
uses: docker/build-push-action@v6
with:
context: csharp
file: csharp/Dockerfile
load: true
platforms: linux/amd64
tags: hgresume-csharp:test

- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'

- name: Run HTTP-level tests against the image
working-directory: csharp
env:
# The test fixture drives a container via this CLI (docker on the runner); it starts/stops
# the pre-built image itself, so we skip the fixture's own build step.
HGRESUME_PODMAN: docker
HGRESUME_IMAGE: hgresume-csharp:test
HGRESUME_SKIP_BUILD: '1'
HGRESUME_PORT: '8034'
run: dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal"

build:
name: Push docker image to Github packages
name: Build and push docker image to GitHub packages
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- name: Checkout
Expand All @@ -21,10 +63,16 @@ jobs:
- name: Set Version
run: |
echo "VERSION=v$(date --rfc-3339=date)" >> ${GITHUB_ENV}

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to the Container registry
# Fork PRs get a read-only GITHUB_TOKEN; skip login/push so the job still builds without a 403.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
Expand All @@ -36,14 +84,19 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# For a pull request this produces the tag `pr-<number>`, so the image can be pulled and
# tested in a real environment: `docker pull ghcr.io/<repo>:pr-<number>`.
tags: |
type=ref,event=branch
${{ env.VERSION }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
type=ref,event=pr
type=raw,value=${{ env.VERSION }},enable=${{ github.event_name != 'pull_request' }}

- name: Build and push Docker image (C#)
uses: docker/build-push-action@v6
with:
context: .
push: true
context: csharp
file: csharp/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
labels: ${{ steps.meta.outputs.labels }}
10 changes: 10 additions & 0 deletions csharp/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Build context is this directory (`csharp/`); the Dockerfile only COPY src/.
# Keep the ~20 MB HttpTests fixture tree (and the rest of the harness) out of every build.
test/
**/bin/
**/obj/
*.md
*.slnx
run-tests.sh
run-tests.ps1
.gitignore
9 changes: 9 additions & 0 deletions csharp/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
bin/
obj/
*.user
.idea/
.vs/

# Bundled Mercurial dropped into the SendReceive test project by SIL.Chorus.Mercurial at build time
test/HgResume.SendReceiveTests/Mercurial/
test/HgResume.SendReceiveTests/MercurialExtensions/
36 changes: 36 additions & 0 deletions csharp/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1

# ---- build -------------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY src/HgResume.Api/HgResume.Api.csproj src/HgResume.Api/
RUN dotnet restore src/HgResume.Api/HgResume.Api.csproj
COPY src/ src/
RUN dotnet publish src/HgResume.Api/HgResume.Api.csproj -c Release -o /app /p:UseAppHost=false

# ---- runtime -----------------------------------------------------------------
FROM mcr.microsoft.com/dotnet/aspnet:10.0
# mercurial: required for all hg operations (shelled out to, same as the PHP app).
RUN apt-get update \
&& apt-get install -y --no-install-recommends mercurial \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

# Run as root so we can bind port 80 and freely read/write the repo + cache volumes
# (auth is handled by the surrounding platform, matching the PHP www-data deployment model).
USER root

RUN mkdir -p /var/cache/hgresume /var/vcs/public /var/vcs/private

ENV ASPNETCORE_URLS=http://+:80
ENV HGRESUME_CACHE_PATH=/var/cache/hgresume
ENV HGRESUME_REPO_PATHS="/var/vcs/public;/var/vcs/private"
ENV HGRESUME_MAINTENANCE_FILE=/var/cache/hgresume/maintenance_message.txt

EXPOSE 80
VOLUME /var/cache/hgresume
VOLUME /var/vcs/public

WORKDIR /app
COPY --from=build /app .
ENTRYPOINT ["dotnet", "HgResume.Api.dll"]
9 changes: 9 additions & 0 deletions csharp/HgResume.slnx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/HgResume.Api/HgResume.Api.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/HgResume.HttpTests/HgResume.HttpTests.csproj" />
<Project Path="test/HgResume.SendReceiveTests/HgResume.SendReceiveTests.csproj" />
</Folder>
</Solution>
67 changes: 67 additions & 0 deletions csharp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# hgresume — C# / ASP.NET Core rewrite

A drop-in, wire-compatible reimplementation of the PHP `hgresume` API (see `../api`) that provides a
server-side REST API for **resumable Mercurial bundle transfer**. It is a faithful port of the
`dockerize` (`c905288`) PHP lineage: same endpoints, same `X-HgR-*` header protocol, same status-code
mapping, and the same shell-out-to-`hg` behaviour — so existing Chorus clients work unchanged.

Authentication is intentionally **not** implemented here; it is handled by the surrounding platform
(reverse proxy / gateway), matching how the container is deployed.

## Layout

- `src/HgResume.Api/` — the ASP.NET Core app (net10.0).
- `RestDispatcher` — routes on the last path segment (`/api/v03/<method>`), binds query/body params
(including `baseHashes[]`), and writes the `X-HgR-*` response contract.
- `HgResumeApi` — push/pull/getRevisions/finish*/isAvailable, faithful to the PHP state machine.
- `HgRunner` — shells out to `hg` (incoming/unbundle/bundle `-t v1`/log/branches/tip), parsing stdout verbatim.
- `AsyncRunner` — runs long hg commands in the background and signals completion via a `.async_run`
file, so a later HTTP request can observe the result (this is what makes transfers resumable).
- `BundleHelper` — per-transaction state + metadata (stored as JSON).
- `test/HgResume.HttpTests/` — HTTP-level xUnit tests ported from `api/test/HgResumeApi_Test.php`. They
drive the **running container** over HTTP (via a podman-managed fixture) and assert on the protocol.
- `Dockerfile` — multi-stage `dotnet/sdk:10.0` → `dotnet/aspnet:10.0`, installs `mercurial`.
Listens on port 80 and exposes the same `/var/cache/hgresume` and `/var/vcs/public` volumes as the
PHP image, so it is a drop-in replacement in the existing `docker-compose.yaml`.
- `.dockerignore` — keeps `test/` (including large fixture zips) out of the image build context.

## Configuration (environment variables)

| Variable | Default | Purpose |
|---|---|---|
| `HGRESUME_CACHE_PATH` | `/var/cache/hgresume` | bundle + transaction cache |
| `HGRESUME_REPO_PATHS` | `/var/vcs/public;/var/vcs/private` | `;`-separated repo search paths |
| `HGRESUME_MAINTENANCE_FILE` | `<cache>/maintenance_message.txt` | non-empty file ⇒ 503 maintenance mode |
| `HGRESUME_MAX_REQUEST_BODY_SIZE` | `30000000` | max request body bytes (Kestrel; raise for whole-bundle pushes) |
| `ASPNETCORE_URLS` | `http://+:80` | listen address |

## Build & run

```bash
podman build -t hgresume-csharp:test -f Dockerfile .
podman run -d --name hgresume -p 8034:80 \
-v /path/to/repos:/var/vcs/public \
hgresume-csharp:test
curl -i http://localhost:8034/api/v03/isAvailable
```

## Tests

The HTTP-level suite builds the image, runs it in a container, seeds fixture repos by extracting
zips on the host and `podman cp`-ing them in, and exercises the protocol end-to-end:

```bash
./run-tests.sh # or: pwsh ./run-tests.ps1
```

Useful env overrides: `HGRESUME_IMAGE`, `HGRESUME_PORT`, `HGRESUME_SKIP_BUILD`, and
`HGRESUME_BASE_URL` + `HGRESUME_CONTAINER` (to run the tests against an already-running container).

## CI

`.github/workflows/docker-image.yml` builds this image and, on a pull request, pushes it to GHCR tagged
`pr-<number>` (multi-arch amd64/arm64) so it can be pulled and tested in a real environment:

```bash
docker pull ghcr.io/sillsdev/hgresume:pr-<number>
```
28 changes: 28 additions & 0 deletions csharp/run-tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env pwsh
# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container.
# The test fixture starts/stops the container itself; this script just builds the image first.
param(
[string]$Image = "hgresume-csharp:test",
[string]$Port = "8034",
[switch]$SkipBuild
)

$ErrorActionPreference = "Stop"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
Push-Location $here
try {
if (-not $SkipBuild) {
Write-Host "==> Building image $Image" -ForegroundColor Cyan
podman build -t $Image -f Dockerfile .
}

$env:HGRESUME_IMAGE = $Image
$env:HGRESUME_PORT = $Port
$env:HGRESUME_SKIP_BUILD = "1" # already built above

Write-Host "==> Running HTTP-level tests against the image" -ForegroundColor Cyan
dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal"
}
finally {
Pop-Location
}
21 changes: 21 additions & 0 deletions csharp/run-tests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Builds the C# hgresume image with podman, then runs the HTTP-level test suite against a container.
# The test fixture starts/stops the container itself; this script just builds the image first.
set -euo pipefail

IMAGE="${HGRESUME_IMAGE:-hgresume-csharp:test}"
PORT="${HGRESUME_PORT:-8034}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$here"

if [ "${1:-}" != "--skip-build" ]; then
echo "==> Building image $IMAGE"
podman build -t "$IMAGE" -f Dockerfile .
fi

export HGRESUME_IMAGE="$IMAGE"
export HGRESUME_PORT="$PORT"
export HGRESUME_SKIP_BUILD=1

echo "==> Running HTTP-level tests against the image"
dotnet test test/HgResume.HttpTests/HgResume.HttpTests.csproj --logger "console;verbosity=normal"
53 changes: 53 additions & 0 deletions csharp/src/HgResume.Api/ApiConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
namespace HgResume.Api;

/// <summary>
/// Runtime configuration. Mirrors api/src/config.php (CACHE_PATH, repoSearchPaths, API_VERSION)
/// plus the maintenance-message file location (PHP: SourcePath . "/maintenance_message.txt").
/// All values are overridable via environment variables so the container and tests can point
/// at alternate paths.
/// </summary>
public sealed class ApiConfig
{
public const int ApiVersion = 3; // PHP: define('API_VERSION', 3)

/// <summary>Kestrel's stock default (30_000_000). PHP deployments had an analogous post_max_size.</summary>
public const long DefaultMaxRequestBodySize = 30_000_000;

public required string CachePath { get; init; }
public required IReadOnlyList<string> RepoSearchPaths { get; init; }
public required string MaintenanceFilePath { get; init; }

/// <summary>
/// Max pushBundleChunk (and any other) request body in bytes. Maps to
/// KestrelServerLimits.MaxRequestBodySize. Oversize bodies get a bare 413 before the dispatcher.
/// </summary>
public required long MaxRequestBodySize { get; init; }

public static ApiConfig FromEnvironment()
{
string cache = Env("HGRESUME_CACHE_PATH", "/var/cache/hgresume");
string repos = Env("HGRESUME_REPO_PATHS", "/var/vcs/public;/var/vcs/private");
string maintenance = Env("HGRESUME_MAINTENANCE_FILE", Path.Combine(cache, "maintenance_message.txt"));

return new ApiConfig
{
CachePath = cache,
RepoSearchPaths = repos
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries),
MaintenanceFilePath = maintenance,
MaxRequestBodySize = EnvLong("HGRESUME_MAX_REQUEST_BODY_SIZE", DefaultMaxRequestBodySize),
};
}

private static string Env(string name, string fallback)
{
string? v = Environment.GetEnvironmentVariable(name);
return string.IsNullOrWhiteSpace(v) ? fallback : v;
}

private static long EnvLong(string name, long fallback)
{
string? v = Environment.GetEnvironmentVariable(name);
return long.TryParse(v, out var n) && n > 0 ? n : fallback;
}
}
Loading
Loading