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