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/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
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 @@
-
-
-
-
+