From e14e4743644eec325153b50bc4d188b37da32530 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 6 Sep 2026 20:10:59 +0000 Subject: [PATCH 1/2] Run the test suite on CI, and give it something to run The tests artifact has always been empty: dist-tests still listed the IKVM.ByteCode.Tests targets from the template it was copied from, so it published nothing, and no job ever executed it. Point it at Alethic.Seq.Operator.Tests on net10.0. Add a test job to the workflow, running the published test assembly directly on win-x64, linux-x64 and linux-arm64. The tests use Microsoft.Testing.Platform, so they are executed as an assembly rather than through 'dotnet test', which fails on .NET 10. Release now waits on test rather than build, so a red suite blocks publishing. Give the suite its first tests, covering the finalizer identifiers. These are the strings written into metadata.finalizers, and KubeOps derives them from the finalizer class name; the reconciler inspects Finalizers()[0] only and returns without removing an identifier nothing is registered for. Renaming a finalizer or deleting a legacy shim therefore wedges every live entity carrying the old identifier in Terminating. The tests pin the exact wire strings, assert current and retired never overlap, hold both within the 63 character limit, and check the table and the finalizer classes agree in both directions, so neither a rename nor a deleted shim can pass silently. The test project references the operator, which sets SelfContained; ValidateExecutableReferencesMatchSelfContained is disabled there so the reference is allowed without changing how the operator is published. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M3mJmvzVTi5AtFhQComUcs --- .github/workflows/Alethic.Seq.Operator.yml | 57 +++++- .../Alethic.Seq.Operator.Tests.csproj | 8 + .../EntityFinalizersTests.cs | 172 ++++++++++++++++++ src/dist-tests/dist-tests.msbuildproj | 5 +- 4 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs diff --git a/.github/workflows/Alethic.Seq.Operator.yml b/.github/workflows/Alethic.Seq.Operator.yml index 54640eb..98b1478 100644 --- a/.github/workflows/Alethic.Seq.Operator.yml +++ b/.github/workflows/Alethic.Seq.Operator.yml @@ -93,11 +93,66 @@ jobs: with: name: tests path: /tmp/tests.tar.gz + test: + strategy: + fail-fast: false + matrix: + run: + - Alethic.Seq.Operator.Tests + sys: + - win-x64 + - linux-x64 + - linux-arm64 + tfm: + - net10.0 + name: Test (${{ matrix.run }}:${{ matrix.tfm }}:${{ matrix.sys }}) + needs: + - build + runs-on: ${{ fromJSON('{"win-x64":["windows-2022"],"linux-x64":["ubuntu-24.04"],"linux-arm64":["ubuntu-24.04-arm"]}')[matrix.sys] }} + steps: + - name: Setup .NET 10 + uses: actions/setup-dotnet@v6 + with: + dotnet-version: 10.x + - name: Download Tests + uses: actions/download-artifact@v8 + with: + name: tests + - name: Restore Tests + run: tar xzvf tests.tar.gz + - name: Execute Tests + timeout-minutes: 60 + shell: pwsh + run: | + # assign powershell variables + $run = "${{ matrix.run }}" + $tfm = "${{ matrix.tfm }}" + $sys = "${{ matrix.sys }}" + + # tests use Microsoft.Testing.Platform: execute the published test assembly directly, not `dotnet test` + $test = $(gi .\tests\$run\$tfm\$run.dll) + if ($test) { + Add-Content $env:GITHUB_ENV "`nRET=TestResults--$run--$tfm--$sys" + dotnet exec $test.FullName --results-directory TestResults --report-trx + } else { + throw "no test assembly found for $run/$tfm" + } + - name: Archive Test Results + if: always() && startsWith(env.RET, 'TestResults--') + shell: pwsh + run: tar czvf TestResults.tar.gz TestResults + - name: Upload Test Results + if: always() && startsWith(env.RET, 'TestResults--') + uses: actions/upload-artifact@v7 + with: + name: ${{ env.RET }} + path: TestResults.tar.gz + release: name: Release if: github.event_name != 'pull_request' needs: - - build + - test runs-on: ubuntu-24.04 permissions: contents: write diff --git a/src/Alethic.Seq.Operator.Tests/Alethic.Seq.Operator.Tests.csproj b/src/Alethic.Seq.Operator.Tests/Alethic.Seq.Operator.Tests.csproj index ccf1a88..7fcd9b7 100644 --- a/src/Alethic.Seq.Operator.Tests/Alethic.Seq.Operator.Tests.csproj +++ b/src/Alethic.Seq.Operator.Tests/Alethic.Seq.Operator.Tests.csproj @@ -5,6 +5,14 @@ latest enable true + + false + + + + diff --git a/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs b/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs new file mode 100644 index 0000000..2721f3f --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs @@ -0,0 +1,172 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Alethic.Seq.Operator.Alerts; +using Alethic.Seq.Operator.ApiKey; +using Alethic.Seq.Operator.Finalizers; +using Alethic.Seq.Operator.Instance; +using Alethic.Seq.Operator.RetentionPolicy; +using Alethic.Seq.Operator.Signals; + +using KubeOps.Abstractions.Reconciliation.Finalizer; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Pins the exact strings this operator writes into metadata.finalizers. + /// + /// + /// These are not cosmetic. KubeOps derives a finalizer's identifier from its class name, and the reconciler + /// inspects Finalizers()[0] only, returning without removing it when nothing is registered under that + /// identifier. Renaming a finalizer, or deleting one of the legacy shims, therefore wedges every live entity + /// carrying the old identifier in Terminating forever. A failure here means an upgrade would strand + /// objects in the cluster. + /// + [TestClass] + public class EntityFinalizersTests + { + + const string Group = "seq.k8s.datalust.co"; + + /// + /// Maximum length of a finalizer identifier, as enforced by KubeOps. + /// + const int MaxIdentifierLength = 63; + + /// + /// The identifier attached to new entities of each kind. Changing one of these is a breaking change for + /// every object already in a cluster. + /// + static IEnumerable<(Type Entity, string Identifier)> CurrentIdentifiers() + { + yield return (typeof(V1alpha1Alert), "seq.k8s.datalust.co/alertfinalizer"); + yield return (typeof(V1alpha1ApiKey), "seq.k8s.datalust.co/apikeyfinalizer"); + yield return (typeof(V1alpha1Instance), "seq.k8s.datalust.co/instancefinalizer"); + yield return (typeof(V1alpha1RetentionPolicy), "seq.k8s.datalust.co/retentionpolicyfinalizer"); + yield return (typeof(V1alpha1Signal), "seq.k8s.datalust.co/signalfinalizer"); + } + + /// + /// Identifiers written by previous releases. They are no longer attached, but stay registered so that + /// entities already carrying them can drain. + /// + static IEnumerable<(Type Entity, string Identifier)> RetiredIdentifiers() + { + yield return (typeof(V1alpha1Alert), "seq.k8s.datalust.co/v1alpha1alertfinalizer"); + yield return (typeof(V1alpha1ApiKey), "seq.k8s.datalust.co/v1alpha1apikeyfinalizer"); + yield return (typeof(V1alpha1Instance), "seq.k8s.datalust.co/v1alpha1instancefinalizer"); + yield return (typeof(V1alpha1RetentionPolicy), "seq.k8s.datalust.co/v1alpha1retentionpolicyfinalizer"); + yield return (typeof(V1alpha1Signal), "seq.k8s.datalust.co/v1alpha1signalfinalizer"); + } + + /// + /// Invokes the generic for a runtime type. + /// + /// + static string? Current(Type entity) + { + return (string?)typeof(EntityFinalizers) + .GetMethod(nameof(EntityFinalizers.Current))! + .MakeGenericMethod(entity) + .Invoke(null, null); + } + + /// + /// Invokes the generic for a runtime type. + /// + /// + static IReadOnlyCollection Retired(Type entity) + { + return (IReadOnlyCollection)typeof(EntityFinalizers) + .GetMethod(nameof(EntityFinalizers.Retired))! + .MakeGenericMethod(entity) + .Invoke(null, null)!; + } + + /// + /// Reproduces the identifier KubeOps derives from a finalizer's class name. + /// + /// + static string Identifier(Type finalizer) + { + var name = finalizer.Name.ToLowerInvariant(); + if (name.EndsWith("finalizer") == false) + name += "finalizer"; + + var identifier = $"{Group}/{name}"; + return identifier.Length > MaxIdentifierLength ? identifier.Substring(0, MaxIdentifierLength) : identifier; + } + + /// + /// Every concrete finalizer in the operator assembly, paired with the entity it finalizes. + /// + static IEnumerable<(Type Finalizer, Type Entity)> RegisteredFinalizers() + { + foreach (var type in typeof(EntityFinalizers).Assembly.GetTypes()) + { + if (type.IsClass == false || type.IsAbstract) + continue; + + foreach (var iface in type.GetInterfaces()) + if (iface.IsGenericType && iface.GetGenericTypeDefinition() == typeof(IEntityFinalizer<>)) + yield return (type, iface.GetGenericArguments()[0]); + } + } + + [TestMethod] + public void CurrentIdentifiersAreExactlyAsReleased() + { + foreach (var (entity, identifier) in CurrentIdentifiers()) + Assert.AreEqual(identifier, Current(entity), $"current finalizer identifier for {entity.Name} changed"); + } + + [TestMethod] + public void RetiredIdentifiersAreStillRegistered() + { + foreach (var (entity, identifier) in RetiredIdentifiers()) + Assert.IsTrue(Retired(entity).Contains(identifier), $"retired finalizer identifier {identifier} is no longer registered; entities carrying it could never finish deleting"); + } + + [TestMethod] + public void CurrentIdentifierIsNeverAlsoRetired() + { + foreach (var (entity, identifier) in CurrentIdentifiers()) + Assert.IsFalse(Retired(entity).Contains(identifier), $"{identifier} is both attached and stripped, so reconciliation would never settle"); + } + + [TestMethod] + public void IdentifiersFitTheKubernetesLimit() + { + foreach (var identifier in CurrentIdentifiers().Concat(RetiredIdentifiers()).Select(i => i.Identifier)) + Assert.IsTrue(identifier.Length <= MaxIdentifierLength, $"{identifier} is {identifier.Length} characters, over the {MaxIdentifierLength} KubeOps allows"); + } + + [TestMethod] + public void EveryFinalizerClassIsAccountedForInTheTable() + { + foreach (var (finalizer, entity) in RegisteredFinalizers()) + { + var identifier = Identifier(finalizer); + var known = Current(entity) == identifier || Retired(entity).Contains(identifier); + Assert.IsTrue(known, $"{finalizer.Name} resolves to {identifier}, which EntityFinalizers does not list for {entity.Name}"); + } + } + + [TestMethod] + public void EveryTableEntryHasAFinalizerClass() + { + var registered = RegisteredFinalizers() + .Select(i => (i.Entity, Identifier: Identifier(i.Finalizer))) + .ToHashSet(); + + foreach (var (entity, identifier) in CurrentIdentifiers().Concat(RetiredIdentifiers())) + Assert.IsTrue(registered.Contains((entity, identifier)), $"EntityFinalizers lists {identifier} for {entity.Name}, but no finalizer class produces it, so KubeOps could never resolve it"); + } + + } + +} diff --git a/src/dist-tests/dist-tests.msbuildproj b/src/dist-tests/dist-tests.msbuildproj index 723a229..6958484 100644 --- a/src/dist-tests/dist-tests.msbuildproj +++ b/src/dist-tests/dist-tests.msbuildproj @@ -6,10 +6,7 @@ - - - - + From e6a82d9e50849439054f89dd7c7ac751472cda8e Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 6 Sep 2026 20:16:08 +0000 Subject: [PATCH 2/2] Make the operator self-contained only for RID builds All six tests failed on linux-arm64 while passing on win-x64 and linux-x64, from the same artifact, with FileNotFoundException loading the operator assembly. The tests artifact is built once and shared by every leg, so an identical file set resolving on two architectures and not the third is RID-scoped asset resolution: SelfContained was set project-wide, which makes even a plain build produce RID-specific output, so the assembly reaches a referencing project only under the RIDs listed in RuntimeIdentifiers, win-x64 and linux-x64. linux-arm64 had nothing to load. Condition SelfContained on a RuntimeIdentifier being set. The container publish always sets one, so the published image is unchanged; a plain build and the test project's reference are now framework-dependent and architecture-neutral. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M3mJmvzVTi5AtFhQComUcs --- src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj b/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj index dff880f..312733a 100644 --- a/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj +++ b/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj @@ -5,7 +5,12 @@ net10.0 win-x64;linux-x64 enable - true + + true seq-operator-image dotnet tool run kubeops true