From 7999e3cdde71f98c9eed0dac281ad6e62f7d7d12 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Fri, 2 Oct 2026 14:17:13 -0400 Subject: [PATCH] fix(server): repair Docker build and verify persistent client round trips --- .dockerignore | 15 + OpenNest.Server/Dockerfile | 2 + docs/nest-storage.md | 91 ++++- scripts/Server.Tests/Program.cs | 407 +++++++++++++++++++++++ scripts/Server.Tests/Server.Tests.csproj | 13 + scripts/Test-ServerContainer.sh | 210 ++++++++++++ scripts/test_server_container_cleanup.py | 242 ++++++++++++++ 7 files changed, 973 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 scripts/Server.Tests/Program.cs create mode 100644 scripts/Server.Tests/Server.Tests.csproj create mode 100755 scripts/Test-ServerContainer.sh create mode 100644 scripts/test_server_container_cleanup.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d37127f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +** +!OpenNest.Server/ +!OpenNest.Server/** +!OpenNest.Data/ +!OpenNest.Data/** +!OpenNest.Core/ +!OpenNest.Core/** +**/bin/** +**/obj/** +**/data/** +**/*.db +**/*.db-* +**/.env* +**/*.user +**/Properties/launchSettings.json diff --git a/OpenNest.Server/Dockerfile b/OpenNest.Server/Dockerfile index 97a8269..488e72f 100644 --- a/OpenNest.Server/Dockerfile +++ b/OpenNest.Server/Dockerfile @@ -4,10 +4,12 @@ WORKDIR /src COPY OpenNest.Server/OpenNest.Server.csproj OpenNest.Server/ COPY OpenNest.Data/OpenNest.Data.csproj OpenNest.Data/ +COPY OpenNest.Core/OpenNest.Core.csproj OpenNest.Core/ RUN dotnet restore OpenNest.Server/OpenNest.Server.csproj COPY OpenNest.Server/ OpenNest.Server/ COPY OpenNest.Data/ OpenNest.Data/ +COPY OpenNest.Core/ OpenNest.Core/ RUN dotnet publish OpenNest.Server/OpenNest.Server.csproj -c Release -o /app --no-restore FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime diff --git a/docs/nest-storage.md b/docs/nest-storage.md index d72b1ea..5310a79 100644 --- a/docs/nest-storage.md +++ b/docs/nest-storage.md @@ -74,16 +74,93 @@ dotnet run --project OpenNest.Server/OpenNest.Server.csproj Listens on `ASPNETCORE_URLS` (default Kestrel ports) unless overridden. -Docker (built from the repository root so it can see `OpenNest.Data`): +Docker must be built from the repository root: the server references +`OpenNest.Data`, which references `OpenNest.Core`. The Dockerfile copies all three +project files before restore and their source/resources before publish, including +Data's embedded `Defaults/CL-980.json`. The allowlist `.dockerignore` excludes +other projects, host `bin`/`obj`, local databases, environment files, and launch +settings. The server image uses the .NET 8 SDK to build and the .NET 8 ASP.NET +runtime to run; IO/Engine are used only by the host-side smoke tool, not the image. ```sh -docker build -f OpenNest.Server/Dockerfile -t opennest-server . -docker run -d -p 8090:8090 -v opennest-data:/app/data opennest-server +docker build --pull -f OpenNest.Server/Dockerfile -t opennest-server:local . ``` -The image listens on `:8090` and stores `nests.db` under `/app/data`, which -should be a named volume or bind mount so nests survive container recreation. +The image listens on `:8090` and keeps `OPENNEST_DB=/app/data/nests.db`. Mount +`/app/data` to preserve the SQLite database and archive blobs across recreation. +For an intentional local run (not needed for the isolated tests below): + +```sh +docker run -d -p 127.0.0.1:8090:8090 -v opennest-data:/app/data opennest-server:local +``` + +This example binds only to loopback. Remote shop clients require an explicitly +chosen trusted-interface binding and network access controls; do not casually +replace it with an all-interface publish. The service has no authentication. +These build/smoke instructions do not establish release readiness, non-root +hardening, or a production deployment. Point the desktop app's **File > Storage Mode...** server URL at -`http://:8090` (no trailing slash required; `RemoteNestRepository` -trims it). +`http://:8090` (or `http://127.0.0.1:8090` for local use). + +## Isolated container smoke + +Prerequisites: a local Linux Docker daemon on the default Unix-socket context, +Bash, curl, GNU `timeout`, `mktemp`, and a .NET SDK able to build `net8.0` projects +(.NET 8 or newer). Restore needs NuGet access or cached packages. Run from the +repository root after building the image: + +```sh +scripts/Test-ServerContainer.sh --image opennest-server:local +# Optional durable logs outside the default ignored .hermes/progress directory: +scripts/Test-ServerContainer.sh --image opennest-server:local --results /path/to/results +``` + +The wrapper accepts an image, not a URL or an existing volume/container. It uses +only the local default Docker context, creates uniquely labeled disposable +resources, and publishes to `127.0.0.1` on a Docker-allocated port. It builds the +smoke tool once into a private temporary directory, honoring `TMPDIR`; readiness, +HTTP requests, and child commands have watchdogs. + +The test-only `scripts/Server.Tests/Server.Tests.csproj` console exercises the +existing .NET storage client; it is not included in the server image. + +The synthetic fixture is generated in code: one plate and one rectangular part, +with no customer files. `seed` uses the actual `RemoteNestRepository` and +`NestSaveSession` to create, update the same ID, and save a copy with a new ID. +It checks read-back metadata, session binding, exact list membership, raw +camelCase/string-enum JSON, identical downloaded bytes, and `NestReader` geometry +and counts. `reject` snapshots every record's metadata and archive hash, then +checks 400 responses for missing/invalid/null metadata and missing/empty files on +both upload and file-update routes; unknown metadata/file IDs must return 404. +Every rejected operation must leave the snapshot unchanged. + +The persistence stage stops and removes the first container while retaining its +named volume, starts a replacement on another allocated loopback port, and runs +`verify` against saved IDs, **all** metadata (including server-assigned `savedAt`), +archive hashes, and exact list membership. A missing/invalid state file or failed +assertion exits nonzero. Direct tool use is test-only: it accepts plain loopback +URLs, and `seed` refuses a nonempty server unless `--test-allow-nonempty` is +explicitly supplied for an owned disposable target. Prefer the wrapper; loopback +alone is not proof that an existing server is disposable. + +Each invocation writes a fresh `run.*` results directory containing build, smoke, +container, ownership, and cleanup logs. On failure those logs remain for diagnosis. +The exit trap removes only invocation-owned containers and the named volume; +transient state, synthetic databases, and build artifacts are removed on success +or failure. It never prunes Docker or alters preexisting services/volumes. +Cleanup requires successful empty Docker inventory readback, including for an +already-removed container. Unresolved ownership, lookup, removal, or readback +errors make the wrapper fail without deleting unproven resources; inspect +`cleanup.log` and `outcome.log` for diagnostics. An original smoke failure is +preserved even if cleanup also fails. + +The focused cleanup regression needs only Python 3 and Bash: + +```sh +python3 -m unittest discover -s scripts -p test_server_container_cleanup.py -v +``` + +It executes the wrapper's actual cleanup functions with explicitly mocked Docker +transport, including timeouts and missing cidfiles; it does not replace the real +container smoke or contact a daemon. diff --git a/scripts/Server.Tests/Program.cs b/scripts/Server.Tests/Program.cs new file mode 100644 index 0000000..35017f4 --- /dev/null +++ b/scripts/Server.Tests/Program.cs @@ -0,0 +1,407 @@ +using System.Globalization; +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using OpenNest.Data; +using OpenNest.Geometry; +using OpenNest.IO; +using CncProgram = OpenNest.CNC.Program; + +namespace OpenNest.Server.Tests; + +internal static class Program +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true, + Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }, + }; + + private static async Task Main(string[] args) + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + try + { + var options = Options.Parse(args); + using var watchdog = new CancellationTokenSource(TimeSpan.FromSeconds(90)); + using var handler = new WireContractHandler(); + using var http = new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10), + MaxResponseContentBufferSize = 16 * 1024 * 1024, + }; + using var repository = new RemoteNestRepository(http, options.Url); + switch (options.Mode) + { + case "seed": + await Seed(repository, options, watchdog.Token); + break; + case "verify": + await Verify(repository, options, watchdog.Token); + break; + case "reject": + await Reject(repository, http, options.Url, watchdog.Token); + break; + } + Console.WriteLine($"PASS {options.Mode}; raw camelCase/string-enum responses checked: {handler.CheckedResponses}"); + return 0; + } + catch (SmokeAssertionException ex) + { + Console.Error.WriteLine($"FAIL: {ex.Message}"); + return 1; + } + catch (Exception ex) + { + // Do not print URLs, response bodies, or metadata from an accidental target. + Console.Error.WriteLine($"FAIL: {ex.GetType().Name}; smoke did not complete."); + return 1; + } + } + + private static async Task Seed(RemoteNestRepository repository, Options options, CancellationToken ct) + { + var existing = await repository.ListAsync(ct); + Check(existing.Count == 0 || options.AllowNonempty, + "Seed refuses a nonempty server; use only an owned disposable server."); + var baseline = await Snapshot(repository, ct); + var nest = SyntheticNest(); + var session = new NestSaveSession(); + Check(session.RemoteId == Guid.Empty && session.ServerUrl == "", "New session must be unbound."); + var archive = Archive(nest); + var created = await session.SaveAsync(repository, options.Url, nest, archive, cancellationToken: ct); + Check(created.Id != Guid.Empty, "Create must return a nonempty id."); + CheckBinding(session, created.Id, options.Url); + await ReadSaved(repository, nest, archive, created, ct); + await CheckMembership(repository, baseline.Select(s => s.Metadata.Id).Append(created.Id), ct); + + nest.Name = "Synthetic container smoke updated"; + nest.Customer = "Synthetic test customer updated"; + nest.Notes = "Updated neutral smoke metadata"; + nest.MadeBy = "Synthetic smoke editor"; + nest.Material = new Material("Synthetic alloy updated", "test-grade", 0.3); + nest.Thickness = 0.375; + nest.Status = NestStatus.ToBeCut; + nest.Plates[0].Parts[0].Location = new Vector(4, 5); + archive = Archive(nest); + var updated = await session.SaveAsync(repository, options.Url, nest, archive, cancellationToken: ct); + Check(updated.Id == created.Id, "Update must retain the create id."); + CheckBinding(session, created.Id, options.Url); + var updateState = await ReadSaved(repository, nest, archive, updated, ct); + await CheckMembership(repository, baseline.Select(s => s.Metadata.Id).Append(created.Id), ct); + + nest.Name = "Synthetic container smoke copy"; + nest.Status = NestStatus.HasBeenCut; + archive = Archive(nest); + var copy = await session.SaveAsync(repository, options.Url, nest, archive, saveCopy: true, + cancellationToken: ct); + Check(copy.Id != Guid.Empty && copy.Id != created.Id, "Save copy must create a different id."); + CheckBinding(session, copy.Id, options.Url); + var copyState = await ReadSaved(repository, nest, archive, copy, ct); + var entries = baseline.Concat(new[] { updateState, copyState }).ToList(); + await CheckSnapshot(repository, entries, ct); + var state = new SmokeState { Version = 1, Records = entries }; + await File.WriteAllTextAsync(options.State!, JsonSerializer.Serialize(state, JsonOptions), ct); + Console.WriteLine($"Seed stored {entries.Count} records; create/update id={updated.Id}; copy id={copy.Id}"); + foreach (var entry in new[] { updateState, copyState }) + Console.WriteLine($"Archive id={entry.Metadata.Id}; bytes={entry.Metadata.FileSize}; sha256={entry.Sha256}"); + } + + private static async Task Verify(RemoteNestRepository repository, Options options, CancellationToken ct) + { + Check(File.Exists(options.State), "Verify state is missing."); + var state = JsonSerializer.Deserialize(await File.ReadAllTextAsync(options.State!, ct), JsonOptions); + Check(state is { Version: 1, Records.Count: >= 2 }, "Verify state is invalid."); + var entries = state!.Records; + Check(entries.All(e => e is not null && e.Metadata is not null && e.Metadata.Id != Guid.Empty + && e.Metadata.SavedAt != default && e.Metadata.FileSize > 0 + && e.Sha256 is not null && e.Sha256.Length == 64 && e.Sha256.All(Uri.IsHexDigit)), + "Verify state contains invalid metadata or hashes."); + Check(entries.Select(e => e.Metadata.Id).Distinct().Count() == entries.Count, + "Verify state contains duplicate ids."); + await CheckSnapshot(repository, entries, ct); + Console.WriteLine($"Verified {entries.Count} persisted records, all metadata including SavedAt, and archive hashes."); + } + + private static async Task Reject(RemoteNestRepository repository, HttpClient http, string url, CancellationToken ct) + { + var before = await Snapshot(repository, ct); + Check(before.Count > 0, "Rejection checks require seeded records on an owned server."); + var metadata = JsonSerializer.Serialize(new NestRecord { Name = "Synthetic rejected upload" }, JsonOptions); + var cases = new (string Name, string? Metadata, byte[]? File)[] + { + ("missing metadata", null, new byte[] { 1 }), + ("invalid metadata JSON", "{not-json", new byte[] { 1 }), + ("null metadata", "null", new byte[] { 1 }), + ("missing file", metadata, null), + ("empty file", metadata, Array.Empty()), + }; + // Exercise both shared multipart callers, including mutation of an existing record. + foreach (var method in new[] { HttpMethod.Post, HttpMethod.Put }) + { + var path = method == HttpMethod.Post ? "api/nests" : $"api/nests/{before[0].Metadata.Id}/file"; + foreach (var test in cases) + { + using var content = new MultipartFormDataContent(); + if (test.Metadata is not null) + content.Add(new StringContent(test.Metadata), "metadata"); + if (test.File is not null) + content.Add(new ByteArrayContent(test.File), "file", "synthetic.nest"); + using var request = new HttpRequestMessage(method, new Uri(new Uri(url + "/"), path)) { Content = content }; + using var response = await http.SendAsync(request, ct); + Check(response.StatusCode == HttpStatusCode.BadRequest, $"{method} {test.Name} must return 400."); + await CheckSnapshot(repository, before, ct); + Console.WriteLine($"Rejected {method} {test.Name}: 400; all metadata and hashes unchanged."); + } + } + var unknown = Guid.NewGuid(); + Check(before.All(e => e.Metadata.Id != unknown), "Unknown-id fixture collided."); + Check(await repository.GetMetadataAsync(unknown, ct) is null, "Unknown metadata id must return 404."); + Check(await repository.GetFileAsync(unknown, ct) is null, "Unknown file id must return 404."); + await CheckSnapshot(repository, before, ct); + Console.WriteLine("Unknown metadata/file ids: 404; all metadata and hashes unchanged."); + } + + private static async Task ReadSaved(RemoteNestRepository repository, Nest nest, + byte[] archive, NestRecord returned, CancellationToken ct) + { + Check(returned.SavedAt != default, "Server must assign SavedAt."); + var expected = NestRecordFactory.FromNest(nest, returned.Id, archive.LongLength); + expected.SavedAt = returned.SavedAt; + CheckMetadata(expected, returned); + var readback = await repository.GetMetadataAsync(returned.Id, ct); + Check(readback is not null, "Saved metadata must be readable."); + CheckMetadata(expected, readback!); + var downloaded = await repository.GetFileAsync(returned.Id, ct); + Check(downloaded is not null && downloaded.SequenceEqual(archive), "Downloaded archive bytes must match upload."); + var geometry = Geometry(nest); + CheckArchive(downloaded!, readback!, geometry); + return new SavedRecord { Metadata = readback!, Sha256 = Hash(downloaded!), Geometry = geometry }; + } + + private static async Task> Snapshot(RemoteNestRepository repository, CancellationToken ct) + { + var records = await repository.ListAsync(ct); + Check(records.Count <= 100, "Smoke snapshot refuses more than 100 records."); + Check(records.Select(r => r.Id).Distinct().Count() == records.Count, "List ids must be unique."); + var snapshot = new List(); + foreach (var record in records.OrderBy(r => r.Id)) + { + var readback = await repository.GetMetadataAsync(record.Id, ct); + Check(readback is not null, "Snapshot metadata must be readable."); + CheckMetadata(record, readback!); + var file = await repository.GetFileAsync(record.Id, ct); + Check(file is not null && file.LongLength == record.FileSize, "Snapshot archive size must match metadata."); + snapshot.Add(new SavedRecord { Metadata = readback!, Sha256 = Hash(file!) }); + } + return snapshot; + } + + private static async Task CheckSnapshot(RemoteNestRepository repository, + IReadOnlyList expected, CancellationToken ct) + { + await CheckMembership(repository, expected.Select(e => e.Metadata.Id), ct); + var actual = await Snapshot(repository, ct); + foreach (var entry in expected) + { + var readback = actual.Single(e => e.Metadata.Id == entry.Metadata.Id); + CheckMetadata(entry.Metadata, readback.Metadata); + Check(entry.Sha256 == readback.Sha256, "Persisted archive SHA256 must match state."); + if (entry.Geometry is not null) + { + var archive = await repository.GetFileAsync(entry.Metadata.Id, ct); + Check(archive is not null, "Persisted archive must be readable."); + CheckArchive(archive!, entry.Metadata, entry.Geometry); + } + } + } + + private static async Task CheckMembership(RemoteNestRepository repository, + IEnumerable ids, CancellationToken ct) + { + var expected = ids.Order().ToArray(); + var actual = (await repository.ListAsync(ct)).Select(r => r.Id).Order().ToArray(); + Check(expected.SequenceEqual(actual), "List must contain exactly the expected ids."); + } + + private static void CheckMetadata(NestRecord expected, NestRecord actual) => + Check(JsonSerializer.Serialize(expected, JsonOptions) == JsonSerializer.Serialize(actual, JsonOptions), + "All metadata fields must round-trip exactly."); + + private static void CheckBinding(NestSaveSession session, Guid id, string url) => + Check(session.RemoteId == id && session.ServerUrl == url, "Session must bind to the saved id and server URL."); + + private static Nest SyntheticNest() + { + var program = new CncProgram(); + program.MoveTo(0, 0); + program.LineTo(6, 0); + program.LineTo(6, 3); + program.LineTo(0, 3); + program.LineTo(0, 0); + var drawing = new Drawing("Synthetic rectangle", program); + drawing.Quantity.Required = 1; + var nest = new Nest("Synthetic container smoke") + { + Customer = "Synthetic test customer", + DateCreated = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Unspecified), + Material = new Material("Synthetic alloy", "test-grade", 0.25), + Thickness = 0.25, + Status = NestStatus.Quote, + Notes = "Neutral generated fixture; no customer data", + MadeBy = "Synthetic smoke author", + Units = Units.Inches, + }; + nest.Drawings.Add(drawing); + var plate = new Plate(new Size(20, 30)) { Quantity = 1 }; + plate.Parts.Add(new Part(drawing, new Vector(2, 3))); + nest.Plates.Add(plate); + Check(drawing.Area == 18, "Synthetic rectangle must have closed area 18."); + return nest; + } + + private static byte[] Archive(Nest nest) + { + using var stream = new MemoryStream(); + Check(new NestWriter(nest).Write(stream), "NestWriter must succeed."); + return stream.ToArray(); + } + + private static GeometryState Geometry(Nest nest) => new() + { + PlateWidth = nest.Plates[0].Size.Width, + PlateLength = nest.Plates[0].Size.Length, + PartX = nest.Plates[0].Parts[0].Location.X, + PartY = nest.Plates[0].Parts[0].Location.Y, + DrawingArea = nest.Drawings.Single().Area, + }; + + private static void CheckArchive(byte[] archive, NestRecord metadata, GeometryState geometry) + { + using var stream = new MemoryStream(archive, writable: false); + var reader = new NestReader(stream); + var nest = reader.Read(); + Check(reader.Warnings.Count == 0, "NestReader must parse without warnings."); + var parsed = NestRecordFactory.FromNest(nest, metadata.Id, archive.LongLength); + parsed.SavedAt = metadata.SavedAt; + CheckMetadata(metadata, parsed); + Check(nest.Drawings.Count == 1 && nest.Plates.Count == 1 && nest.Plates[0].Parts.Count == 1, + "Parsed archive must contain one drawing, plate, and part."); + Check(nest.Drawings.Single().Quantity.Required == 1 && nest.Drawings.Single().Quantity.Nested == 1, + "Parsed drawing quantities must match."); + Check(nest.Plates[0].Size.Width == geometry.PlateWidth && nest.Plates[0].Size.Length == geometry.PlateLength + && nest.Plates[0].Parts[0].Location.X == geometry.PartX && nest.Plates[0].Parts[0].Location.Y == geometry.PartY + && nest.Drawings.Single().Area == geometry.DrawingArea && nest.Drawings.Single().Program.Codes.Count == 5, + "Parsed plate/rectangle geometry must match."); + } + + private static string Hash(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + + private static void Check(bool condition, string message) + { + if (!condition) + throw new SmokeAssertionException(message); + } + + private sealed class SmokeAssertionException(string message) : Exception(message); + + private sealed record Options(string Mode, string Url, string? State, bool AllowNonempty) + { + public static Options Parse(string[] args) + { + Check(args.Length > 0 && args[0] is "seed" or "verify" or "reject", "Mode must be seed, verify, or reject."); + string? url = null; + string? state = null; + var allowNonempty = false; + for (var i = 1; i < args.Length; i++) + { + if (args[i] == "--url" && url is null && i + 1 < args.Length) + url = args[++i]; + else if (args[i] == "--state" && state is null && i + 1 < args.Length) + state = args[++i]; + else if (args[i] == "--test-allow-nonempty" && !allowNonempty) + allowNonempty = true; + else + throw new SmokeAssertionException("Invalid or duplicate smoke argument."); + } + Check(Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttp + && uri.Host == "127.0.0.1" && uri.Port > 0 && uri.AbsolutePath == "/" + && uri.UserInfo == "" && uri.Query == "" && uri.Fragment == "", + "Smoke requires a plain http://127.0.0.1: URL without credentials, path, query, or fragment."); + Check(args[0] == "reject" ? state is null && !allowNonempty : !string.IsNullOrWhiteSpace(state), + "Seed/verify require --state; reject does not accept state or an override."); + Check(args[0] == "seed" || !allowNonempty, "Nonempty override is TEST-only and seed-only."); + return new Options(args[0], uri!.GetLeftPart(UriPartial.Authority), state, allowNonempty); + } + } + + private sealed class SmokeState + { + public int Version { get; set; } + public List Records { get; set; } = new(); + } + + private sealed class SavedRecord + { + public NestRecord Metadata { get; set; } = new(); + public string Sha256 { get; set; } = ""; + public GeometryState? Geometry { get; set; } + } + + private sealed class GeometryState + { + public double PlateWidth { get; set; } + public double PlateLength { get; set; } + public double PartX { get; set; } + public double PartY { get; set; } + public double DrawingArea { get; set; } + } + + // Observe the real repository's responses; do not reimplement its HTTP endpoints. + private sealed class WireContractHandler : DelegatingHandler + { + private static readonly string[] Fields = typeof(NestRecord).GetProperties() + .Select(p => JsonNamingPolicy.CamelCase.ConvertName(p.Name)).Order().ToArray(); + public int CheckedResponses { get; private set; } + + public WireContractHandler() : base(new HttpClientHandler { AllowAutoRedirect = false, UseProxy = false }) { } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + var response = await base.SendAsync(request, ct); + try + { + if (response.IsSuccessStatusCode && !(request.Method == HttpMethod.Get + && request.RequestUri!.AbsolutePath.EndsWith("/file", StringComparison.Ordinal))) + { + using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct)); + if (json.RootElement.ValueKind == JsonValueKind.Array) + { + foreach (var record in json.RootElement.EnumerateArray()) + CheckWireRecord(record); + } + else + CheckWireRecord(json.RootElement); + CheckedResponses++; + } + return response; + } + catch + { + response.Dispose(); + throw; + } + } + + private static void CheckWireRecord(JsonElement record) + { + Check(record.ValueKind == JsonValueKind.Object + && record.EnumerateObject().Select(p => p.Name).Order().SequenceEqual(Fields), + "Raw JSON must contain exactly all camelCase metadata fields."); + var status = record.GetProperty("status"); + Check(status.ValueKind == JsonValueKind.String + && status.GetString() is "quote" or "toBeCut" or "hasBeenCut", "Raw JSON status must be a camelCase string enum."); + } + } +} diff --git a/scripts/Server.Tests/Server.Tests.csproj b/scripts/Server.Tests/Server.Tests.csproj new file mode 100644 index 0000000..ed3e5db --- /dev/null +++ b/scripts/Server.Tests/Server.Tests.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + OpenNest.Server.Tests + enable + enable + + + + + + diff --git a/scripts/Test-ServerContainer.sh b/scripts/Test-ServerContainer.sh new file mode 100755 index 0000000..d6330b6 --- /dev/null +++ b/scripts/Test-ServerContainer.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# Isolated local-only smoke; never accepts an existing container, volume, or URL. +set -Eeuo pipefail +umask 077 + +# A whole-run bound also leaves time for the exit trap before an outer CI watchdog. +if [[ ${OPENNEST_SMOKE_WATCHDOG:-} != 1 ]]; then + command -v timeout >/dev/null || { printf 'Missing prerequisite: timeout\n' >&2; exit 2; } + exec timeout --signal=TERM --kill-after=30s 450s env OPENNEST_SMOKE_WATCHDOG=1 \ + bash "${BASH_SOURCE[0]}" "$@" +fi + +usage() { + printf 'Usage: %s --image [--results ]\n' "$0" >&2 +} +image='' +results='' +while (($#)); do + case "$1" in + --image) [[ $# -ge 2 && -z "$image" ]] || { usage; exit 2; }; image=$2; shift 2 ;; + --results) [[ $# -ge 2 && -z "$results" ]] || { usage; exit 2; }; results=$2; shift 2 ;; + *) usage; exit 2 ;; + esac +done +[[ -n "$image" && "$image" != -* ]] || { usage; exit 2; } +for command in docker dotnet curl timeout mktemp; do + command -v "$command" >/dev/null || { printf 'Missing prerequisite: %s\n' "$command" >&2; exit 2; } +done +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd) +# Honor TMPDIR, including all dotnet build outputs and transient synthetic state. +tmp_root=${TMPDIR:-"$HOME/.cache"} +mkdir -p -- "$tmp_root" +results=${results:-"$root/.hermes/progress/server-container-smoke"} +mkdir -p -- "$results" +# Set up logs before allocating scratch, so results-path failures cannot leak it. +# Each invocation gets a private child directory; existing result files are never overwritten. +run_results=$(mktemp -d "$results/run.XXXXXXXXXX") +scratch=$(mktemp -d "$tmp_root/opennest-server-smoke.XXXXXXXXXX") +token=${scratch##*/} +owner_label='com.opennest.server-smoke.owner' +volume="$token-data" +container_names=("$token-first" "$token-replacement") +cidfiles=("$scratch/first.cid" "$scratch/replacement.cid") +volume_attempted=false +container_attempted=(false false) + +# Explicit local default context, ignoring environment overrides for remote daemons. +docker_local() { timeout 20s env -u DOCKER_HOST -u DOCKER_CONTEXT docker --context default "$@"; } +owned_container() { + [[ $(docker_local inspect --format "{{index .Config.Labels \"$owner_label\"}}" "$1") == "$token" ]] +} +cleanup_absent() { + local kind=$1 target=$2 remaining filter + local -a inventory + if [[ "$kind" == container ]]; then + filter="id=$target" + if [[ "$target" == "${container_names[0]}" || "$target" == "${container_names[1]}" ]]; then + # Generated names contain only alphanumerics, hyphens, and dots. + filter="name=^/${target//./\\.}$" + fi + inventory=(container ls --all --quiet --no-trunc --filter "$filter") + else + inventory=(volume ls --quiet --filter "name=$target") + fi + # A failed inspect is not absence. Only a successful, empty inventory proves it. + if remaining=$(docker_local "${inventory[@]}" 2>> "$run_results/cleanup.log"); then + if [[ -z "$remaining" ]]; then + printf 'Confirmed absent: %s %s\n' "$kind" "$target" >> "$run_results/cleanup.log" + return 0 + fi + printf 'Cleanup unknown/incomplete: %s %s remains in inventory: %s\n' \ + "$kind" "$target" "$remaining" >> "$run_results/cleanup.log" + else + printf 'Cleanup unknown: %s %s inventory failed status=%s\n' \ + "$kind" "$target" "$?" >> "$run_results/cleanup.log" + fi + return 1 +} +cleanup_resource() { + local kind=$1 target=$2 index=${3:-} metadata resource owner + local -a inspect remove + if [[ "$kind" == container ]]; then + inspect=(inspect --format "{{.Id}} {{index .Config.Labels \"$owner_label\"}}") + remove=(rm -f) + else + inspect=(volume inspect --format "{{.Name}} {{index .Labels \"$owner_label\"}}") + remove=(volume rm) + fi + if metadata=$(docker_local "${inspect[@]}" "$target" 2>> "$run_results/cleanup.log"); then + read -r resource owner <<< "$metadata" + if [[ -z "$resource" || "$owner" != "$token" ]]; then + printf 'Cleanup unknown: %s %s owner could not be reconciled; refusing removal.\n' \ + "$kind" "$target" >> "$run_results/cleanup.log" + return 1 + fi + if [[ "$kind" == container ]]; then + # Name recovery resolves a full owned ID before removal, even without a cidfile. + if [[ ! "$resource" =~ ^[0-9a-f]{64}$ || ( "$target" != "${container_names[0]}" \ + && "$target" != "${container_names[1]}" && "$resource" != "$target" ) ]]; then + printf 'Cleanup unknown: container %s identity mismatch; refusing removal.\n' \ + "$target" >> "$run_results/cleanup.log" + return 1 + fi + docker_local logs "$resource" > "$run_results/container-$index.log" 2>&1 + docker_local inspect --format 'id={{.Id}} running={{.State.Running}} exit={{.State.ExitCode}}' \ + "$resource" > "$run_results/container-$index-state.log" 2>&1 + elif [[ "$resource" != "$target" ]]; then + printf 'Cleanup unknown: volume %s identity mismatch; refusing removal.\n' \ + "$target" >> "$run_results/cleanup.log" + return 1 + fi + docker_local "${remove[@]}" "$resource" >> "$run_results/cleanup.log" 2>&1 || { + printf 'Cleanup incomplete: %s %s removal failed status=%s\n' \ + "$kind" "$resource" "$?" >> "$run_results/cleanup.log" + return 1 + } + cleanup_absent "$kind" "$resource" + else + printf 'Cleanup lookup failed: %s %s status=%s; checking absence.\n' \ + "$kind" "$target" "$?" >> "$run_results/cleanup.log" + cleanup_absent "$kind" "$target" + fi +} +cleanup() { + local status=$? cleanup_failed=0 cid index + trap - EXIT INT TERM + set +e + for index in 0 1; do + [[ ${container_attempted[$index]} == true ]] || continue + cid='' + if [[ -s ${cidfiles[$index]} ]]; then + read -r cid < "${cidfiles[$index]}" || [[ -n "$cid" ]] + fi + cleanup_resource container "${cid:-${container_names[$index]}}" "$index" || cleanup_failed=1 + done + # Docker volume create may return a preexisting name: delete only a matching owner label. + if [[ "$volume_attempted" == true ]]; then + cleanup_resource volume "$volume" || cleanup_failed=1 + fi + rm -rf -- "$scratch" + if ((cleanup_failed != 0 && status == 0)); then status=1; fi + printf 'exit=%s cleanup_failed=%s\n' "$status" "$cleanup_failed" > "$run_results/outcome.log" + printf 'Smoke exit=%s; logs: %s\n' "$status" "$run_results" + exit "$status" +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +endpoint=$(docker_local context inspect default --format '{{.Endpoints.docker.Host}}') +[[ "$endpoint" == 'unix:///var/run/docker.sock' ]] || { printf 'Refusing a non-local default Docker endpoint.\n' >&2; exit 2; } +docker_local info --format 'daemon={{.Name}} os={{.OSType}}' > "$run_results/daemon.log" +docker_local image inspect --format 'image={{.Id}}' "$image" > "$run_results/image.log" +printf 'host_sdk=%s\n' "$(dotnet --version)" > "$run_results/toolchain.log" +printf 'owner=%s volume=%s\n' "$token" "$volume" > "$run_results/ownership.log" +timeout 180s dotnet build "$root/scripts/Server.Tests/Server.Tests.csproj" -c Release \ + --artifacts-path "$scratch/build" -m:1 /nodeReuse:false > "$run_results/build.log" 2>&1 +dll="$scratch/build/bin/Server.Tests/release/Server.Tests.dll" +[[ -f "$dll" ]] || { printf 'Smoke build did not produce the expected DLL.\n' >&2; exit 1; } +# Refuse collisions before creation; the post-create label is the authoritative ownership proof. +if docker_local volume inspect "$volume" >/dev/null 2>&1; then + printf 'Refusing a preexisting volume name.\n' >&2; exit 1 +fi +volume_attempted=true +docker_local volume create --label "$owner_label=$token" "$volume" > "$run_results/volume.log" +[[ $(docker_local volume inspect --format "{{index .Labels \"$owner_label\"}}" "$volume") == "$token" ]] \ + || { printf 'Volume ownership could not be established.\n' >&2; exit 1; } + +start_container() { + local index=$1 cid binding + if docker_local inspect "${container_names[$index]}" >/dev/null 2>&1; then + printf 'Refusing a preexisting container name.\n' >&2; return 1 + fi + container_attempted[$index]=true + docker_local create --name "${container_names[$index]}" --cidfile "${cidfiles[$index]}" \ + --label "$owner_label=$token" --publish 127.0.0.1::8090 \ + --mount "type=volume,src=$volume,dst=/app/data" "$image" > "$run_results/create-$index.log" + read -r cid < "${cidfiles[$index]}" || [[ -n "$cid" ]] + owned_container "$cid" || { printf 'Container ownership could not be established.\n' >&2; return 1; } + docker_local start "$cid" > "$run_results/start-$index.log" + binding=$(docker_local port "$cid" 8090/tcp) + [[ "$binding" =~ ^127\.0\.0\.1:([0-9]+)$ ]] || { printf 'Unexpected port binding.\n' >&2; return 1; } + url="http://127.0.0.1:${BASH_REMATCH[1]}" + printf 'id=%s binding=%s\n' "$cid" "$binding" >> "$run_results/ownership.log" + local deadline=$((SECONDS + 45)) + while ((SECONDS < deadline)); do + [[ $(docker_local inspect --format '{{.State.Running}}' "$cid") == true ]] \ + || { printf 'Container exited before readiness.\n' >&2; return 1; } + if curl --noproxy '*' --fail --silent --show-error --connect-timeout 1 --max-time 2 \ + "$url/healthz" > "$scratch/health.json" 2>> "$run_results/readiness-$index.log"; then + [[ $(< "$scratch/health.json") == '{"status":"ok"}' ]] && return 0 + fi + sleep 1 + done + printf 'Container readiness timed out.\n' >&2; return 1 +} + +start_container 0 +timeout 100s dotnet "$dll" seed --url "$url" --state "$scratch/state.json" > "$run_results/seed.log" 2>&1 +timeout 100s dotnet "$dll" reject --url "$url" > "$run_results/reject.log" 2>&1 +read -r first_cid < "${cidfiles[0]}" || [[ -n "$first_cid" ]] +docker_local logs "$first_cid" > "$run_results/container-0.log" 2>&1 +docker_local inspect --format 'id={{.Id}} running={{.State.Running}} exit={{.State.ExitCode}}' \ + "$first_cid" > "$run_results/container-0-state.log" +docker_local stop --time 10 "$first_cid" > "$run_results/stop-0.log" +docker_local rm "$first_cid" > "$run_results/remove-0.log" +# The replacement gets another Docker-allocated loopback port; only the volume is reused. +start_container 1 +timeout 100s dotnet "$dll" verify --url "$url" --state "$scratch/state.json" > "$run_results/verify.log" 2>&1 +printf 'PASS: real-client create/update/copy, rejections, and persistent recreation.\n' diff --git a/scripts/test_server_container_cleanup.py b/scripts/test_server_container_cleanup.py new file mode 100644 index 0000000..9d1437b --- /dev/null +++ b/scripts/test_server_container_cleanup.py @@ -0,0 +1,242 @@ +"""Unit fault injection: real Bash cleanup functions, explicitly virtual Docker. + +No daemon, .NET build, HTTP endpoint, or real Docker resources are used here. +""" + +import json +import os +from pathlib import Path +import re +import shlex +import subprocess +import sys +import tempfile +import unittest + + +TOKEN = "cleanup-unit-owner" +FIRST_ID = "a" * 64 +CID = "b" * 64 +FIRST_NAME = "cleanup-unit.first" +NAME = "cleanup-unit.replacement" +VOLUME = "cleanup-unit-data" + + +def mock_docker(state_path, args): + """Model only the Docker transport calls needed by the cleanup contract.""" + path = Path(state_path) + state = json.loads(path.read_text()) + state["calls"].append(args) + path.write_text(json.dumps(state)) + kind = "volume" if args[0] == "volume" else "container" + command = args[1] if kind == "volume" else args[0] + resources = state["volumes"] if kind == "volume" else state["containers"] + + def fail(message, status): + print(message, file=sys.stderr) + return status + + if command == "inspect": + status = state.get("lookup_status", 0) if kind == state.get("fault_kind") else 0 + if status: + return fail(f"Injected Docker {kind} lookup failure status={status}", status) + target = args[-1] + resource = next( + (key for key, value in resources.items() if target in (key, value.get("name"))), + None, + ) + if resource is None: + return fail(f"Error: No such {kind}: {target}", 1) + template = args[args.index("--format") + 1] + owner = resources[resource]["owner"] + if "Labels" in template: + identity = ".Name" if kind == "volume" else ".Id" + print(f"{resource} {owner}" if identity in template else owner) + elif template == "{{.Id}}": + print(resource) + else: + print(f"id={resource} running=true exit=0") + return 0 + if command == "ls" or args[:2] == ["container", "ls"]: + status = state.get("inventory_status", 0) + if state.get("inventory_after_removal") and not state["removals"]: + status = 0 + if status: + return fail(f"Injected Docker inventory failure status={status}", status) + filter_value = args[args.index("--filter") + 1] + field, target = filter_value.split("=", 1) + for key, value in resources.items(): + if field == "id": + matches = key.startswith(target) + elif kind == "container": + matches = re.search(target, "/" + value["name"]) is not None + else: + matches = re.search(target, key) is not None + if matches: + print(key) + return 0 + if command == "rm": + target = args[-1] + state["removals"].append([kind, target]) + mode = state.get("remove_mode") if kind == state.get("fault_kind") else None + if mode not in ("error", "no_effect"): + del resources[target] + path.write_text(json.dumps(state)) + if mode == "error": + return fail("Injected Docker removal failure", 1) + print(target) + return 0 + if command == "logs": + print("Virtual container log; no real daemon contacted") + return 0 + return fail(f"Unexpected mock Docker call: {args!r}", 97) + + +class CleanupTests(unittest.TestCase): + def run_cleanup(self, *, missing_cidfile=False, absent_first=False, + absent_all=False, unowned=False, attempted=True, + original_status=0, **faults): + source = Path(__file__).with_name("Test-ServerContainer.sh").read_text() + # Extract the actual candidate functions, never a copied cleanup implementation. + functions = source[source.index("owned_container() {"):source.index("trap cleanup EXIT")] + temp_root = Path(os.environ.get("TMPDIR", str(Path.home() / ".cache"))) + temp_root.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="opennest-cleanup-unit.", dir=temp_root) as temp: + root = Path(temp) + scratch = root / "scratch" + logs = root / "results" + scratch.mkdir() + logs.mkdir() + if not missing_cidfile: + (scratch / "replacement.cid").write_text(CID + "\n") + if absent_first: + (scratch / "first.cid").write_text(FIRST_ID + "\n") + owner = "someone-else" if unowned else TOKEN + state_path = root / "virtual-docker.json" + state = { + "containers": {} if absent_all else {CID: {"name": NAME, "owner": owner}}, + "volumes": {} if absent_all else {VOLUME: {"owner": owner}}, + "calls": [], "removals": [], **faults, + } + state_path.write_text(json.dumps(state)) + q = shlex.quote + script = "\n".join([ + "set -Eeuo pipefail", + f"scratch={q(str(scratch))}", + f"run_results={q(str(logs))}", + "owner_label='com.opennest.server-smoke.owner'", + f"token={q(TOKEN)}", + f"volume={q(VOLUME)}", + f"volume_attempted={str(attempted).lower()}", + f"container_attempted=({str(absent_first and attempted).lower()} {str(attempted).lower()})", + f"container_names=({q(FIRST_NAME)} {q(NAME)})", + f"cidfiles=({q(str(scratch / 'first.cid'))} {q(str(scratch / 'replacement.cid'))})", + f'docker_local() {{ {q(sys.executable)} {q(str(Path(__file__).resolve()))} ' + f'--mock-docker {q(str(state_path))} "$@"; }}', + functions, + "trap cleanup EXIT", + f"exit {original_status}", + ]) + run = subprocess.run(["bash", "-c", script], text=True, capture_output=True, timeout=15) + self.assertFalse(scratch.exists(), "owned scratch must be removed on every outcome") + self.assertTrue((logs / "outcome.log").is_file(), run.stderr) + return { + "status": run.returncode, + "outcome": (logs / "outcome.log").read_text(), + "log": (logs / "cleanup.log").read_text() if (logs / "cleanup.log").exists() else "", + "state": json.loads(state_path.read_text()), + "stderr": run.stderr, + } + + def assert_failed(self, result, status=1): + self.assertEqual(status, result["status"], result) + self.assertEqual(f"exit={status} cleanup_failed=1\n", result["outcome"]) + + def test_healthy_owned_resources_are_removed(self): + for missing in (False, True): + with self.subTest(missing_cidfile=missing): + result = self.run_cleanup(missing_cidfile=missing) + self.assertEqual(0, result["status"], result) + self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"]) + self.assertEqual([["container", CID], ["volume", VOLUME]], result["state"]["removals"]) + self.assertFalse(result["state"]["containers"]) + self.assertFalse(result["state"]["volumes"]) + + def test_first_container_already_removed_is_successful_noop(self): + result = self.run_cleanup(absent_first=True) + self.assertEqual(0, result["status"], result) + self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"]) + self.assertNotIn(["container", FIRST_ID], result["state"]["removals"]) + + def test_proven_absence_with_or_without_cidfile_is_success(self): + for missing in (False, True): + with self.subTest(missing_cidfile=missing): + result = self.run_cleanup(absent_all=True, missing_cidfile=missing) + self.assertEqual(0, result["status"], result) + self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"]) + self.assertFalse(result["state"]["removals"]) + + def test_lookup_timeout_or_daemon_error_fails_closed(self): + for kind in ("container", "volume"): + for status in (124, 1): + for missing in (False, True): + with self.subTest(kind=kind, status=status, missing_cidfile=missing): + result = self.run_cleanup(fault_kind=kind, lookup_status=status, + missing_cidfile=missing) + self.assert_failed(result) + self.assertIn(f"Injected Docker {kind} lookup failure status={status}", result["log"]) + self.assertIn("unknown", result["log"].lower()) + self.assertFalse(any(r[0] == kind for r in result["state"]["removals"])) + self.assertTrue(result["state"]["containers" if kind == "container" else "volumes"]) + + def test_failed_inventory_cannot_prove_absence(self): + for status in (124, 1): + for missing in (False, True): + with self.subTest(status=status, missing_cidfile=missing): + result = self.run_cleanup(absent_all=True, missing_cidfile=missing, + inventory_status=status) + self.assert_failed(result) + self.assertIn(f"Injected Docker inventory failure status={status}", result["log"]) + self.assertIn("unknown", result["log"].lower()) + self.assertFalse(result["state"]["removals"]) + + def test_mismatched_owner_is_never_removed_or_claimed_complete(self): + for missing in (False, True): + with self.subTest(missing_cidfile=missing): + result = self.run_cleanup(unowned=True, missing_cidfile=missing) + self.assert_failed(result) + self.assertFalse(result["state"]["removals"]) + self.assertIn("owner", result["log"].lower()) + + def test_preexisting_unattempted_resources_are_untouched(self): + result = self.run_cleanup(unowned=True, attempted=False) + self.assertEqual(0, result["status"], result) + self.assertEqual("exit=0 cleanup_failed=0\n", result["outcome"]) + self.assertFalse(result["state"]["calls"]) + self.assertTrue(result["state"]["containers"]) + self.assertTrue(result["state"]["volumes"]) + + def test_removal_failure_or_false_success_fails_completion(self): + for kind in ("container", "volume"): + for mode in ("error", "no_effect"): + with self.subTest(kind=kind, mode=mode): + result = self.run_cleanup(fault_kind=kind, remove_mode=mode) + self.assert_failed(result) + self.assertTrue(result["state"]["containers" if kind == "container" else "volumes"]) + self.assertTrue(result["log"]) + + def test_inconclusive_post_removal_readback_fails_completion(self): + result = self.run_cleanup(inventory_status=124, inventory_after_removal=True) + self.assert_failed(result) + self.assertIn("Injected Docker inventory failure status=124", result["log"]) + + def test_original_smoke_failure_is_preserved(self): + result = self.run_cleanup(original_status=23, fault_kind="container", lookup_status=124) + self.assert_failed(result, status=23) + self.assertIn("Injected Docker container lookup failure status=124", result["log"]) + + +if __name__ == "__main__": + if len(sys.argv) > 1 and sys.argv[1] == "--mock-docker": + sys.exit(mock_docker(sys.argv[2], sys.argv[3:])) + unittest.main()