From 45f4c65fd31bf2f1f18634088da830bd2293d302 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 10:36:49 -0500 Subject: [PATCH 1/7] Rewrite the README, and fix the admin password generator and StatefulSet serviceName The README is rewritten around what the operator actually does: installing, each resource with examples checked against the generated CRDs, the namespace permission model, deletion, known limitations, building, and releasing with GitVersion. The install command now notes that Helm skips the prerelease builds main publishes. Deployed Instances generated their admin and first-run passwords with Random.Shared, which is not a secure source, and always put a special character first. They now come from RandomNumberGenerator, drawn uniformly and guaranteed one character of each class. The StatefulSet's serviceName named the ServiceAccount rather than the Service. It is now set to the Service on creation only, since the field is immutable; existing StatefulSets keep theirs. --- README.md | 359 +++++++++++++++--- .../PasswordGeneratorTests.cs | 67 ++++ .../Alethic.Seq.Operator.csproj | 4 + .../Instance/V1alpha1InstanceController.cs | 37 +- 4 files changed, 406 insertions(+), 61 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs diff --git a/README.md b/README.md index 099c573..73c826f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,24 @@ -# Kubernetes operator for Seq +# Seq Operator for Kubernetes -## About The Project +A Kubernetes operator for [Seq](https://datalust.co/seq). Describe Seq servers and their configuration as +Kubernetes resources, and the operator keeps the server in step with them. -This Seq Kubernetes Operator is responsible for managing the lifecycle of Seq resources in a Kubernetes cluster. +It can: -### Installation +- **Deploy Seq** into the cluster as a StatefulSet with persistent storage, bootstrap its admin account and + issue itself a management API key. +- **Manage an existing Seq server**, inside or outside the cluster, given a login or an API key. +- **Configure Seq declaratively**: server settings, authentication providers, licensing, API keys, signals and + retention policies. +- **Hand API keys to workloads** as Kubernetes Secrets containing the Seq endpoint and token. + +> [!NOTE] +> The resources are at `v1alpha1`. They work and are in use, but their shape may still change between releases. +> See [Limitations](#limitations). + +## Installation + +The operator is published as a Helm chart and container image on GitHub Container Registry. ```bash helm install seq-operator oci://ghcr.io/alethic/seq-operator/seq-operator \ @@ -12,86 +26,301 @@ helm install seq-operator oci://ghcr.io/alethic/seq-operator/seq-operator \ --create-namespace ``` -## Usage +Without `--version`, Helm installs the latest stable release. Builds from `main` are published as prereleases +(for example `0.1.0-pre.145`); install one with `--version `, or with `--devel` for the newest. + +### Chart values + +| value | default | description | +| --- | --- | --- | +| `operator.image.repository` | `ghcr.io/alethic/seq-operator/seq-operator-image` | Operator image. | +| `operator.image.tag` | chart `appVersion` | Operator image tag. `operator.image.digest` takes precedence if set. | +| `operator.resources` | 50m CPU, 64Mi requested | Operator pod resources. | +| `options.logLevel` | `Information` | Operator log level. | +| `options.defaultImage` | `datalust/seq:latest` | Seq image used by deployed Instances that don't set `deployment.image`. | +| `options.reconciliation.interval` | `00:05:00` | Time between periodic reconciliations. | +| `options.reconciliation.retryInterval` | `00:00:30` | Time before retrying a failed reconciliation. | +| `crds.enabled` | `true` | Install and upgrade the CRDs with the chart. Set `false` to manage them yourself. | +| `crds.keep` | `false` | Annotate the CRDs with `helm.sh/resource-policy: keep` so they survive `helm uninstall`. | + +See [`values.yaml`](charts/seq-operator/values.yaml) for scheduling, security context and other pod settings. + +## Resources + +All resources are in the `seq.k8s.datalust.co/v1alpha1` API group. + +| kind | short name | manages | +| --- | --- | --- | +| `Instance` | `seqinstance` | A Seq server: deployed by the operator, or an existing one it connects to. Holds server settings. | +| `ApiKey` | `seqapikey` | A Seq API key, published to a Kubernetes Secret. | +| `Signal` | `seqsignal` | A shared Seq signal. | +| `RetentionPolicy` | | A Seq retention policy. | +| `Alert` | | A Seq alert (title only; see [Limitations](#limitations)). | + +Everything other than `Instance` points at an Instance with `spec.instanceRef`, and puts the settings it +manages under `spec.conf`. + +### Deploying Seq -This is an example for connecting to and bootstrapping an existing Seq instance deployed with a SEQ_FIRSTRUN_ADMINPASSWORDHASH setting. +An Instance without a `remote` section is deployed by the operator. This is the smallest one: +```yaml +apiVersion: seq.k8s.datalust.co/v1alpha1 +kind: Instance +metadata: + name: seq + namespace: logging +spec: {} ``` +The operator then: + +1. Creates a Secret, `seq-operator-login`, with an `admin` username, a generated password and a separate + generated first-run password. +2. Creates a StatefulSet running Seq with a 32Gi volume, and a Service named after the Instance. Seq is reachable + in the cluster at `http://seq.logging.svc.cluster.local`. +3. Logs in with the first-run password and changes it to the real one. +4. Creates an ApiKey, `seq-operator`, and uses that key to manage the server from then on. + +The admin login is in the `seq-operator-login` Secret. + +The `deployment` section adjusts the deployment. It accepts the usual pod settings: `image`, `resources`, +`env`, `affinity`, `nodeSelector`, `tolerations`, `topologySpreadConstraints`, `imagePullSecrets`, the three +probe types, `annotations` and `labels`. It also takes `persistence` and `service`: + +```yaml +apiVersion: seq.k8s.datalust.co/v1alpha1 +kind: Instance +metadata: + name: seq + namespace: logging +spec: + deployment: + image: datalust/seq:2025.2 + resources: + requests: + cpu: 500m + memory: 2Gi + persistence: + storageClassName: fast-ssd + capacity: 100Gi + service: + type: LoadBalancer +``` + +Pin `image` to a specific Seq version. The default, `datalust/seq:latest`, can upgrade Seq whenever the pod is +rescheduled. + +### Connecting to an existing Seq server + +Give an Instance a `remote` section to manage a server the operator didn't deploy. `remote.auth` lists the +ways to authenticate, tried in order: + +- `token` reads a Secret with a `token` key holding a Seq API key. +- `login` reads a Secret with `username` and `password` keys. If the password is rejected and the Secret also + has a `firstRun` key, the operator logs in with that instead and changes the password to `password`. + +That second case bootstraps a fresh server. Start Seq with `SEQ_FIRSTRUN_ADMINPASSWORDHASH` set to the hash of +the first-run password, and let the operator create its own API key: + +```yaml apiVersion: v1 kind: Secret metadata: - name: seqlogin - namespace: default + name: seq-login + namespace: logging type: Opaque stringData: - username: "admin" - password: "newpassword" - firstRun: "1234" + username: admin + password: "" + firstRun: "" --- - apiVersion: seq.k8s.datalust.co/v1alpha1 kind: Instance metadata: - name: seqinstance - namespace: default + name: seq + namespace: logging spec: remote: - endpoint: http://localhost:5341 + endpoint: http://seq.example.com auth: + # Prefer the API key once the ApiKey below has created it... - token: secretRef: - name: seqtoken + name: seq-management + # ...and fall back to the login until then. - login: secretRef: - name: seqlogin + name: seq-login --- - apiVersion: seq.k8s.datalust.co/v1alpha1 kind: ApiKey metadata: - name: seqtoken - namespace: default + name: seq-management + namespace: logging spec: instanceRef: - name: seqinstance + name: seq secretRef: - name: seqtoken + name: seq-management conf: - title: ManagementKey + title: Kubernetes operator permissions: - - System - - Project + - Read - Write + - Project + - System - Organization - - Read ---- +``` + +### Server settings +`spec.conf` on an Instance sets Seq server settings. Anything left out is not changed. + +```yaml +spec: + conf: + instanceTitle: Production + requireApiKeyForWritingEvents: true + minimumFreeStorageSpace: 10737418240 + backupLocation: /data/backups + backupsToKeep: 7 + backupUtcTimeOfDay: "02:00:00" + license: + secretKeyRef: + name: seq-license + key: license + automaticallyRefresh: true + auth: + oidc: + authority: https://login.example.com + clientId: seq + clientSecret: "" + scopes: [openid, profile, email] + automaticallyProvisionAuthenticatedUsers: true ``` -This is an example for deploying a new Seq instance. Simply don't specify the `remote` section. +`auth` accepts one of `local`, `activeDirectory`, `entra` or `oidc`. + +### API keys + +An ApiKey creates a Seq API key and writes it to a Secret with two keys: `endpoint`, the Seq server's URL, and +`token`, the API key. The Secret is named by `secretRef`, or `-seq-apikey` if that's omitted, and must be in +the ApiKey's namespace. +```yaml +apiVersion: seq.k8s.datalust.co/v1alpha1 +kind: ApiKey +metadata: + name: orders + namespace: orders +spec: + instanceRef: + name: seq + namespace: logging + conf: + title: Orders service + permissions: + - Ingest + inputSettings: + minimumLevel: Information + appliedProperties: + Application: orders +``` + +A workload can then take its Seq settings straight from the Secret: + +```yaml +env: +- name: SEQ_SERVER_URL + valueFrom: + secretKeyRef: + name: orders-seq-apikey + key: endpoint +- name: SEQ_API_KEY + valueFrom: + secretKeyRef: + name: orders-seq-apikey + key: token ``` + +### Signals and retention policies + +The Instance must grant `signals` permission for a Signal to attach; see +[Which namespaces may use an Instance](#which-namespaces-may-use-an-instance). + +```yaml apiVersion: seq.k8s.datalust.co/v1alpha1 -kind: Instance +kind: Signal metadata: - name: seqinstance - namespace: default -spec: {} + name: errors + namespace: logging +spec: + instanceRef: + name: seq + conf: + title: Errors + description: Events at Error level or above + grouping: Explicit + explicitGroupName: Levels + filters: + - description: Errors + filter: "@Level in ['Error', 'Fatal']" + columns: + - expression: Application +--- +apiVersion: seq.k8s.datalust.co/v1alpha1 +kind: RetentionPolicy +metadata: + name: thirty-days + namespace: logging +spec: + instanceRef: + name: seq + conf: + retentionTime: "30.00:00:00" ``` -This will generate a secret for the 'admin' login, with a randomlly generated first-run password and user password. It will bring up the instance. Once up, it will authenticate as the first-run password, change the password to the real-password, then generate an ApiToken which it will use for management purposes from that point forward. +`retentionTime` is a .NET `TimeSpan`, written `d.hh:mm:ss`. + +### Which namespaces may use an Instance + +By default an Instance only accepts ApiKeys, RetentionPolicies and Alerts from its own namespace. Signals are not +in the defaults: an Instance accepts them only with an explicit `signals` permission. To change any of this, set +`spec.permissions`. This lets any namespace labelled `seq-access: "true"` create and attach +ingest-only API keys: + +```yaml +spec: + permissions: + - namespaces: + from: Selector + selector: + matchLabels: + seq-access: "true" + apiKeys: + create: true + attach: true + setTitle: true + setIngest: true +``` + +`from` is one of `Same` (the default), `All`, `Selector` or `None`. Each permission entry also takes `alerts`, +`signals` and `retentionPolicies`, with the same `create` and `attach` flags. Setting `spec.permissions` replaces +the defaults entirely. + +### Deletion + +Each resource carries a finalizer. Deleting an ApiKey, Signal, RetentionPolicy or Alert deletes the matching +object in Seq, provided the operator can still reach the Instance. If it can't, the resource stays in +`Terminating` until the Instance is reachable again or the finalizer is removed by hand. ## Upgrading ### Adopting the CustomResourceDefinitions (one time) -The chart now ships its CRDs as templates rather than from the chart's `crds/` directory, so that -`helm upgrade` applies schema changes. Helm installs `crds/` exactly once and never updates it, which -previously meant applying CRD changes by hand. - -Releases installed before this change own their CRDs outside of Helm, and Helm refuses to take over a -resource it does not own: +The chart installs its CRDs as ordinary templates, so `helm upgrade` keeps them up to date. Releases installed +before this change own their CRDs outside of Helm, and the first upgrade fails with: ``` Error: UPGRADE FAILED: rendered manifests contain a resource that already exists. @@ -99,8 +328,7 @@ Unable to continue with update: CustomResourceDefinition "instances.seq.k8s.data invalid ownership metadata ``` -Helm checks the metadata on the live object, so this cannot be fixed from inside the chart. Label and -annotate the existing CRDs once, before the first upgrade, substituting your release name and namespace: +Hand the existing CRDs to the release once, before upgrading: ```bash RELEASE=seq-operator @@ -115,11 +343,48 @@ for c in alerts apikeys instances retentionpolicys signals; do done ``` -Fresh installations need none of this. +Fresh installs don't need this. -Two chart values control CRD handling: +## Limitations -| value | default | effect | -| --- | --- | --- | -| `crds.enabled` | `true` | Install and upgrade the CRDs with the chart. Set `false` to manage them out of band. | -| `crds.keep` | `false` | When `true`, annotates the CRDs with `helm.sh/resource-policy: keep` so they survive `helm uninstall`. | +- **Alerts** only manage the alert's title. Conditions, notifications and the rest have to be set in Seq. +- **Signals**: filters and columns removed from the spec are not removed from Seq. +- **Persistence** (`deployment.persistence`) can't be changed once an Instance is deployed, since Kubernetes + doesn't allow a StatefulSet's volume claims to change. +- **Namespace selectors** support `matchLabels` but not `matchExpressions`. +- **Restricting the operator to one namespace** is not supported yet; it always watches the whole cluster. +- **SSO client secrets** (`entra.clientKey`, `oidc.clientSecret`) are written in plain text in the Instance spec, + and appear in its status. +- **Secrets in other namespaces**: an Instance's `remote.auth` can name a Secret in any namespace. In a + multi-tenant cluster, restrict who can create Instances. + +## Building + +Requires the .NET 10 SDK and Helm. + +```bash +dotnet tool restore +dotnet msbuild Alethic.Seq.Operator.dist.msbuildproj +``` + +This builds the operator, generates the CRDs and writes the container image, Helm chart and tests to `dist/`. +Run the tests with: + +```bash +dotnet exec dist/tests/Alethic.Seq.Operator.Tests/net10.0/Alethic.Seq.Operator.Tests.dll +``` + +## Releasing + +Versions come from [GitVersion](https://gitversion.net), configured in [`GitVersion.yml`](GitVersion.yml); no +version is written anywhere in the source. Every push to `main` builds and publishes a prerelease, such as +`0.1.0-pre.145`, to GitHub Container Registry. + +To release a stable version, create a GitHub Release on `main` with a new tag named for the version, such as +`0.1.0`. GitVersion takes the version from the tag. The workflow then attaches the image and chart to the +release and pushes both to the registry. Prereleases that follow carry the next patch version. To bump the minor +or major version instead, include `+semver: minor` or `+semver: major` in a commit message. + +## License + +[MIT](LICENSE) diff --git a/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs b/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs new file mode 100644 index 0000000..06d8817 --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Alethic.Seq.Operator.Instance; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Covers the generator for the admin and first-run passwords of deployed Instances. + /// + [TestClass] + public class PasswordGeneratorTests + { + + [TestMethod] + public void GeneratesRequestedLength() + { + foreach (var length in new[] { 4, 20, 64 }) + Assert.AreEqual(length, V1alpha1InstanceController.GeneratePassword(length).Length); + } + + [TestMethod] + public void ContainsEveryCharacterClass() + { + for (var i = 0; i < 1000; i++) + { + var password = V1alpha1InstanceController.GeneratePassword(20); + Assert.IsTrue(password.Any(char.IsAsciiLetterLower), password); + Assert.IsTrue(password.Any(char.IsAsciiLetterUpper), password); + Assert.IsTrue(password.Any(char.IsAsciiDigit), password); + Assert.IsTrue(password.Any(c => !char.IsAsciiLetterOrDigit(c)), password); + } + } + + /// + /// The previous generator always put a special character first. Nothing about a position should be fixed. + /// + [TestMethod] + public void FirstCharacterIsNotFixedToOneClass() + { + var first = new HashSet(); + for (var i = 0; i < 1000; i++) + first.Add(char.IsAsciiLetterOrDigit(V1alpha1InstanceController.GeneratePassword(20)[0])); + + Assert.AreEqual(2, first.Count); + } + + [TestMethod] + public void DoesNotRepeat() + { + var passwords = Enumerable.Range(0, 1000).Select(_ => V1alpha1InstanceController.GeneratePassword(20)).ToHashSet(); + Assert.AreEqual(1000, passwords.Count); + } + + [TestMethod] + public void RejectsLengthTooShortForEveryClass() + { + Assert.ThrowsExactly(() => V1alpha1InstanceController.GeneratePassword(3)); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj b/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj index 312733a..a56124b 100644 --- a/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj +++ b/src/Alethic.Seq.Operator/Alethic.Seq.Operator.csproj @@ -35,4 +35,8 @@ + + + + diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs index f8a4378..10654c4 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs @@ -44,26 +44,33 @@ public partial class V1alpha1InstanceController : IEntityController { + const string PasswordLowerChars = "abcdefghijkmnopqrstuvwxyz"; + const string PasswordUpperChars = "ABCDEFGHJKLMNOPQRSTUVWXYZ"; + const string PasswordDigitChars = "0123456789"; + const string PasswordSpecialChars = @"!#$%&'()*+,-./:;<=>?@[\]_"; + const string PasswordChars = PasswordLowerChars + PasswordUpperChars + PasswordDigitChars + PasswordSpecialChars; + /// - /// Generates a new random password. + /// Generates a new random password from a cryptographically secure source, containing at least one lowercase + /// letter, uppercase letter, digit and special character. /// /// - static string GeneratePassword(int length) + internal static string GeneratePassword(int length) { - const string allowedChars = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNOPQRSTUVWXYZ0123456789"; - const string specialCharacters = @"!#$%&'()*+,-./:;<=>?@[\]_"; - - var chars = (Span)stackalloc char[length]; + if (length < 4) + throw new ArgumentOutOfRangeException(nameof(length), "Password must allow one character of each class."); - for (var i = 0; i < length; i++) + // draw uniformly from the whole set and reject the rare result missing a class, rather than placing one of + // each at fixed positions, which would make those positions guessable + while (true) { - if (i % Random.Shared.Next(3, length) == 0) - chars[i] = specialCharacters[Random.Shared.Next(0, specialCharacters.Length)]; - else - chars[i] = allowedChars[Random.Shared.Next(0, allowedChars.Length)]; + var password = RandomNumberGenerator.GetString(PasswordChars, length); + if (password.AsSpan().IndexOfAny(PasswordLowerChars) >= 0 && + password.AsSpan().IndexOfAny(PasswordUpperChars) >= 0 && + password.AsSpan().IndexOfAny(PasswordDigitChars) >= 0 && + password.AsSpan().IndexOfAny(PasswordSpecialChars) >= 0) + return password; } - - return new string(chars); } /// @@ -510,7 +517,9 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDeploymentSpec deployment, V1StatefulSet statefulSet, V1Secret adminSecret, V1ServiceAccount serviceAccount, V1Service service) { statefulSet.Spec ??= new(); - statefulSet.Spec.ServiceName = serviceAccount.Name(); + // spec.serviceName cannot be changed once the StatefulSet exists, so it is only set on creation. Sets created + // by earlier versions name the ServiceAccount instead of the Service; they keep it, which only costs per-pod DNS. + statefulSet.Spec.ServiceName ??= service.Name(); statefulSet.Spec.PodManagementPolicy = "OrderedReady"; statefulSet.Spec.Replicas = 1; statefulSet.Spec.UpdateStrategy ??= new V1StatefulSetUpdateStrategy(); From 5cba1413ecae9aecddfed2dcd9d2915596fe47db Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 10:46:21 -0500 Subject: [PATCH 2/7] Enforce the ApiKey permission flags on Instances The apiKeys.set* flags in an Instance's spec.permissions were accepted but never checked, so any namespace allowed to create ApiKeys could assign itself System or Organization. They are now enforced, with defaults by sensitivity: Ingest and Public only send events and are allowed unless denied; Read, Write, Project, System and Organization must be granted. An ApiKey asking for more than it may is refused whole, with the reason in its status, and its key in Seq is left as it was. The operator's own key was never recognised as such: it is named -operator, but the check looked for . It now matches, so that key bypasses these checks as intended. --- README.md | 64 +++++++- .../ApiKeyPermissionTests.cs | 152 ++++++++++++++++++ .../ApiKey/V1alpha1ApiKeyController.cs | 58 ++++++- 3 files changed, 262 insertions(+), 12 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs diff --git a/README.md b/README.md index 73c826f..72e1fac 100644 --- a/README.md +++ b/README.md @@ -287,27 +287,65 @@ spec: By default an Instance only accepts ApiKeys, RetentionPolicies and Alerts from its own namespace. Signals are not in the defaults: an Instance accepts them only with an explicit `signals` permission. To change any of this, set -`spec.permissions`. This lets any namespace labelled `seq-access: "true"` create and attach -ingest-only API keys: +`spec.permissions`. + +`spec.permissions` is a list of entries. For each resource, the first entry whose `namespaces` matches the +resource's namespace decides what it may do. `from` is one of `Same` (the default), `All`, `Selector` or `None`. +Setting `spec.permissions` replaces the defaults entirely. + +Each entry takes `apiKeys`, `alerts`, `signals` and `retentionPolicies`, each with `create` (create new objects in +Seq) and `attach` (adopt existing ones). `apiKeys` also controls what a key may be: + +| flag | controls | when not set | +| --- | --- | --- | +| `setTitle` | setting `conf.title` | denied | +| `setIngest` | assigning `Ingest` | **allowed** | +| `setPublic` | assigning `Public` | **allowed** | +| `setRead` | assigning `Read` | denied | +| `setWrite` | assigning `Write` | denied | +| `setProject` | assigning `Project` | denied | +| `setSystem` | assigning `System` | denied | +| `setOrganization` | assigning `Organization` | denied | + +`Ingest` and `Public` only let a key send events, so they're allowed unless an entry sets the flag to `false`. +Everything that reads or changes Seq has to be granted. An ApiKey asking for a permission it isn't allowed is +refused as a whole, with the reason in its status, rather than created with fewer permissions. If the key already +exists in Seq, it's left as it was. + +This lets any namespace create ingest-only keys, while namespaces labelled `seq-access: read` may also create keys +that read: ```yaml spec: permissions: + - namespaces: + from: Same + apiKeys: + create: true + setTitle: true + setRead: true + setWrite: true + setProject: true + setSystem: true + setOrganization: true - namespaces: from: Selector selector: matchLabels: - seq-access: "true" + seq-access: read + apiKeys: + create: true + setTitle: true + setRead: true + - namespaces: + from: All apiKeys: create: true - attach: true setTitle: true - setIngest: true ``` -`from` is one of `Same` (the default), `All`, `Selector` or `None`. Each permission entry also takes `alerts`, -`signals` and `retentionPolicies`, with the same `create` and `attach` flags. Setting `spec.permissions` replaces -the defaults entirely. +The first entry keeps the Instance's own namespace fully privileged. Without it, listing other namespaces would +take that away. ### Deletion @@ -317,6 +355,16 @@ object in Seq, provided the operator can still reach the Instance. If it can't, ## Upgrading +### ApiKey permission flags are enforced + +Earlier versions accepted the `apiKeys.set*` flags but never checked them, so any namespace allowed to create +ApiKeys could assign any Seq permission. They're now enforced as described in +[Which namespaces may use an Instance](#which-namespaces-may-use-an-instance). + +Before upgrading, find ApiKeys that ask for more than `Ingest` or `Public` from outside their Instance's namespace, +and grant what they need in the Instance's `spec.permissions`. Otherwise they will be refused from then on. Their +keys in Seq keep working; they just stop being updated. Instances without `spec.permissions` are unaffected. + ### Adopting the CustomResourceDefinitions (one time) The chart installs its CRDs as ordinary templates, so `helm upgrade` keeps them up to date. Releases installed diff --git a/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs b/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs new file mode 100644 index 0000000..508833b --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +using Alethic.Seq.Operator.ApiKey; +using Alethic.Seq.Operator.Instance; + +using k8s.Models; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Covers which Seq permissions an ApiKey may be assigned, as decided by the referenced Instance's permissions. + /// + [TestClass] + public class ApiKeyPermissionTests + { + + static readonly ApiKeyPermission[] Sensitive = + [ + ApiKeyPermission.Read, + ApiKeyPermission.Write, + ApiKeyPermission.Project, + ApiKeyPermission.System, + ApiKeyPermission.Organization, + ]; + + static V1alpha1Instance Instance(params InstancePermission[]? permissions) => new() + { + Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, + Spec = new V1alpha1InstanceSpec() { Permissions = permissions }, + }; + + static V1Namespace Namespace(string name, IDictionary? labels = null) => new() + { + Metadata = new V1ObjectMeta() { Name = name, Labels = labels }, + }; + + static InstancePermission FromAll(InstanceApiKeyPermissions apiKeys) => new() + { + Namespaces = new InstancePermissionNamespaces() { From = InstancePermissionNamespaceFrom.All }, + ApiKeys = apiKeys, + }; + + static List Denied(V1alpha1Instance instance, string ns, params ApiKeyPermission[] permissions) => + V1alpha1ApiKeyController.GetDeniedPermissions(instance, Namespace(ns), permissions); + + [TestMethod] + public void IngestAndPublicAreAllowedWhenNotMentioned() + { + var instance = Instance(FromAll(new InstanceApiKeyPermissions() { Create = true })); + CollectionAssert.AreEqual(Array.Empty(), Denied(instance, "app", ApiKeyPermission.Ingest, ApiKeyPermission.Public)); + } + + [TestMethod] + public void SensitivePermissionsAreDeniedWhenNotMentioned() + { + var instance = Instance(FromAll(new InstanceApiKeyPermissions() { Create = true })); + CollectionAssert.AreEquivalent(Sensitive, Denied(instance, "app", Sensitive)); + } + + [TestMethod] + public void IngestCanBeDenied() + { + var instance = Instance(FromAll(new InstanceApiKeyPermissions() { Create = true, SetIngest = false })); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.Ingest }, Denied(instance, "app", ApiKeyPermission.Ingest)); + } + + [TestMethod] + public void SensitivePermissionsCanBeGranted() + { + var instance = Instance(FromAll(new InstanceApiKeyPermissions() { Create = true, SetRead = true })); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.System }, Denied(instance, "app", ApiKeyPermission.Read, ApiKeyPermission.System)); + } + + /// + /// Matches the labs configuration: the Instance's own namespace and every other namespace may create keys, with + /// no assignment flags set. App namespaces asking for Ingest keep working; asking for more is refused. + /// + [TestMethod] + public void FirstMatchingEntryDecides() + { + var instance = Instance( + new InstancePermission() + { + Namespaces = new InstancePermissionNamespaces() { From = InstancePermissionNamespaceFrom.Same }, + ApiKeys = new InstanceApiKeyPermissions() { Create = true, SetSystem = true }, + }, + FromAll(new InstanceApiKeyPermissions() { Create = true })); + + CollectionAssert.AreEqual(Array.Empty(), Denied(instance, "seq", ApiKeyPermission.System)); + CollectionAssert.AreEqual(Array.Empty(), Denied(instance, "sweep-dev1", ApiKeyPermission.Ingest)); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.System }, Denied(instance, "sweep-dev1", ApiKeyPermission.Ingest, ApiKeyPermission.System)); + } + + [TestMethod] + public void SelectorScopesTheGrant() + { + var instance = Instance(new InstancePermission() + { + Namespaces = new InstancePermissionNamespaces() + { + From = InstancePermissionNamespaceFrom.Selector, + Selector = new V1LabelSelector() { MatchLabels = new Dictionary() { ["seq-read"] = "true" } }, + }, + ApiKeys = new InstanceApiKeyPermissions() { Create = true, SetRead = true }, + }); + + var labelled = V1alpha1ApiKeyController.GetDeniedPermissions(instance, Namespace("ops", new Dictionary() { ["seq-read"] = "true" }), [ApiKeyPermission.Read]); + CollectionAssert.AreEqual(Array.Empty(), labelled); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.Read }, Denied(instance, "app", ApiKeyPermission.Read)); + } + + /// + /// Without spec.permissions, the Instance's own namespace may assign anything, as before. + /// + [TestMethod] + public void DefaultPermissionsAllowEverythingInTheSameNamespace() + { + var instance = Instance(null); + CollectionAssert.AreEqual(Array.Empty(), Denied(instance, "seq", Sensitive)); + } + + [TestMethod] + public void UndefinedIsAlwaysDenied() + { + var instance = Instance(null); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.Undefined }, Denied(instance, "seq", ApiKeyPermission.Undefined)); + } + +#pragma warning disable CS0618 + [TestMethod] + public void SetupRequiresProjectAndSystem() + { + Assert.IsFalse(V1alpha1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true }, ApiKeyPermission.Setup)); + Assert.IsTrue(V1alpha1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true, SetSystem = true }, ApiKeyPermission.Setup)); + } +#pragma warning restore CS0618 + + [TestMethod] + public void DuplicatesAreReportedOnce() + { + var instance = Instance(FromAll(new InstanceApiKeyPermissions() { Create = true })); + CollectionAssert.AreEqual(new[] { ApiKeyPermission.Read }, Denied(instance, "app", ApiKeyPermission.Read, ApiKeyPermission.Read)); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs index b756dd8..f91836a 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs @@ -72,10 +72,55 @@ bool IsDeploymentToken(V1alpha1Instance instance, V1alpha1ApiKey entity) if (instance.Spec.Remote is not null) return false; - // match to expected admin entity name/namespace - var adminApiKeyNamespace = instance.Spec.Deployment?.TokenSecretRef?.NamespaceProperty ?? instance.Namespace(); - var adminApiKeyName = instance.Name(); - return entity.Namespace() == adminApiKeyNamespace && entity.Name() == adminApiKeyName; + // match to the ApiKey the Instance controller creates for itself, which is always in the Instance's namespace + return entity.Namespace() == instance.Namespace() && entity.Name() == instance.Name() + "-operator"; + } + + /// + /// Returns whether the given allow an ApiKey to be assigned the specified + /// Seq permission, or null if they do not say. + /// + /// + /// + /// + internal static bool? IsPermissionGranted(InstanceApiKeyPermissions? permissions, ApiKeyPermission permission) => permission switch + { + ApiKeyPermission.Public => permissions?.SetPublic, + ApiKeyPermission.Ingest => permissions?.SetIngest, + ApiKeyPermission.Read => permissions?.SetRead, + ApiKeyPermission.Write => permissions?.SetWrite, + ApiKeyPermission.Project => permissions?.SetProject, + ApiKeyPermission.System => permissions?.SetSystem, + ApiKeyPermission.Organization => permissions?.SetOrganization, +#pragma warning disable CS0618 + // Setup was split into Project and System, so it requires both + ApiKeyPermission.Setup => permissions?.SetProject is true && permissions?.SetSystem is true, +#pragma warning restore CS0618 + _ => false, + }; + + /// + /// Returns whether a Seq permission may be assigned when the Instance's permissions do not say. Public and Ingest + /// only allow sending events, so they are allowed unless denied; anything that reads or changes Seq must be granted. + /// + /// + /// + internal static bool IsPermissionGrantedByDefault(ApiKeyPermission permission) => permission is ApiKeyPermission.Public or ApiKeyPermission.Ingest; + + /// + /// Returns the Seq permissions in that the Instance does not permit an ApiKey in the + /// specified namespace to assign. + /// + /// + /// + /// + /// + internal static List GetDeniedPermissions(V1alpha1Instance instance, V1Namespace ns, IEnumerable permissions) + { + return permissions + .Distinct() + .Where(permission => instance.CheckPermission(ns, IsPermissionGrantedByDefault(permission), p => IsPermissionGranted(p.ApiKeys, permission)) == false) + .ToList(); } /// @@ -167,6 +212,11 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti if (setTitle == false && conf?.Title != null) return "title cannot be set explicitly"; + // refused outright rather than assigned partially, so the key in Seq is left as it was + if (conf?.Permissions is { Length: > 0 } permissions && IsDeploymentToken(instance, entity) == false) + if (GetDeniedPermissions(instance, ns, permissions) is { Count: > 0 } denied) + return $"referenced Seq instance does not permit assigning {string.Join(", ", denied)} from this namespace"; + return null; } From f40b1dc3fbab952286f4ef0151c0c9cbeba0f39b Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 10:55:27 -0500 Subject: [PATCH 3/7] Keep SSO secrets out of Instance status, and allow them to come from Secrets The OIDC client secret and Entra client key were read back from Seq into status.info.auth, where anyone with the view role could read them. Status now shows "" in their place. Nothing reads status back: changes are detected against what Seq returns, so redacting does not cause the settings to be rewritten each reconcile. The secrets can now be given as clientSecretRef / clientKeyRef, a key of a Secret in the Instance's namespace, so they need not appear in the spec either. The plain-text fields still work and are documented as deprecated; setting both forms is refused. --- README.md | 19 ++++- .../InstanceStatusRedactionTests.cs | 80 +++++++++++++++++++ .../InstanceConfAuthenticationSpec.cs | 24 ++++++ .../Instance/V1alpha1InstanceController.cs | 60 ++++++++++++-- 4 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs diff --git a/README.md b/README.md index 72e1fac..1957a81 100644 --- a/README.md +++ b/README.md @@ -195,13 +195,20 @@ spec: oidc: authority: https://login.example.com clientId: seq - clientSecret: "" + clientSecretRef: + name: seq-oidc + key: clientSecret scopes: [openid, profile, email] automaticallyProvisionAuthenticatedUsers: true ``` `auth` accepts one of `local`, `activeDirectory`, `entra` or `oidc`. +Give the OIDC client secret as `clientSecretRef`, and the Entra client key as `clientKeyRef`. Each is a key of a +Secret in the Instance's namespace. The plain-text `clientSecret` and `clientKey` fields still work but are +deprecated, since anyone who can read the Instance can read them; set one form or the other, not both. Either way, +the Instance's status shows `` in place of the value. + ### API keys An ApiKey creates a Seq API key and writes it to a Secret with two keys: `endpoint`, the Seq server's URL, and @@ -365,6 +372,14 @@ Before upgrading, find ApiKeys that ask for more than `Ingest` or `Public` from and grant what they need in the Instance's `spec.permissions`. Otherwise they will be refused from then on. Their keys in Seq keep working; they just stop being updated. Instances without `spec.permissions` are unaffected. +### SSO secrets move to Secrets + +Earlier versions copied the OIDC client secret and Entra client key into each Instance's status, where anyone with +the view role could read them. Status now shows `` instead, and existing statuses are rewritten on the next +reconcile. To take the secret out of the spec too, move it into a Secret and replace `clientSecret` with +`clientSecretRef` (or `clientKey` with `clientKeyRef`). Remove the plain-text field when you do: setting both is +refused. + ### Adopting the CustomResourceDefinitions (one time) The chart installs its CRDs as ordinary templates, so `helm upgrade` keeps them up to date. Releases installed @@ -401,8 +416,6 @@ Fresh installs don't need this. doesn't allow a StatefulSet's volume claims to change. - **Namespace selectors** support `matchLabels` but not `matchExpressions`. - **Restricting the operator to one namespace** is not supported yet; it always watches the whole cluster. -- **SSO client secrets** (`entra.clientKey`, `oidc.clientSecret`) are written in plain text in the Instance spec, - and appear in its status. - **Secrets in other namespaces**: an Instance's `remote.auth` can name a Secret in any namespace. In a multi-tenant cluster, restrict who can create Instances. diff --git a/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs b/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs new file mode 100644 index 0000000..ff30a36 --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs @@ -0,0 +1,80 @@ +using Alethic.Seq.Operator.Instance; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Covers the redaction of SSO secrets read back from Seq before they are written to an Instance's status, which + /// anyone with the view role can read. + /// + [TestClass] + public class InstanceStatusRedactionTests + { + + [TestMethod] + public void RedactsEntraClientKey() + { + var info = new InstanceInfo() + { + Auth = new InstanceConfAuthenticationSpec() + { + Entra = new InstanceConfAuthenticationSpec.EntraAuth() { ClientId = "client", ClientKey = "hunter2" }, + }, + }; + + V1alpha1InstanceController.RedactSecrets(info); + + Assert.AreEqual(V1alpha1InstanceController.RedactedValue, info.Auth.Entra.ClientKey); + Assert.AreEqual("client", info.Auth.Entra.ClientId); + } + + [TestMethod] + public void RedactsOidcClientSecret() + { + var info = new InstanceInfo() + { + Auth = new InstanceConfAuthenticationSpec() + { + Oidc = new InstanceConfAuthenticationSpec.OidcAuth() { ClientId = "client", ClientSecret = "hunter2", Authority = "https://login.example.com" }, + }, + }; + + V1alpha1InstanceController.RedactSecrets(info); + + Assert.AreEqual(V1alpha1InstanceController.RedactedValue, info.Auth.Oidc.ClientSecret); + Assert.AreEqual("client", info.Auth.Oidc.ClientId); + Assert.AreEqual("https://login.example.com", info.Auth.Oidc.Authority); + } + + /// + /// An unset secret stays unset, so status still shows whether one has been configured. + /// + [TestMethod] + public void LeavesUnsetSecretsUnset() + { + var info = new InstanceInfo() + { + Auth = new InstanceConfAuthenticationSpec() + { + Oidc = new InstanceConfAuthenticationSpec.OidcAuth() { ClientId = "client", ClientSecret = "" }, + }, + }; + + V1alpha1InstanceController.RedactSecrets(info); + + Assert.AreEqual("", info.Auth.Oidc.ClientSecret); + } + + [TestMethod] + public void ToleratesNoAuth() + { + var info = new InstanceInfo(); + Assert.AreSame(info, V1alpha1InstanceController.RedactSecrets(info)); + Assert.IsNull(info.Auth); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/Instance/InstanceConfAuthenticationSpec.cs b/src/Alethic.Seq.Operator/Instance/InstanceConfAuthenticationSpec.cs index cb3c8ab..59a7a0a 100644 --- a/src/Alethic.Seq.Operator/Instance/InstanceConfAuthenticationSpec.cs +++ b/src/Alethic.Seq.Operator/Instance/InstanceConfAuthenticationSpec.cs @@ -1,5 +1,7 @@ using System.Text.Json.Serialization; +using k8s.Models; + namespace Alethic.Seq.Operator.Instance { @@ -33,10 +35,21 @@ public class EntraAuth [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClientId { get; set; } + /// + /// The client key in plain text. Deprecated: anyone who can read the Instance can read it. Use + /// . + /// [JsonPropertyName("clientKey")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClientKey { get; set; } + /// + /// A key of a Secret in the Instance's namespace holding the client key. + /// + [JsonPropertyName("clientKeyRef")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V1SecretKeySelector? ClientKeyRef { get; set; } + [JsonPropertyName("tenantId")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? TenantId { get; set; } @@ -54,10 +67,21 @@ public class OidcAuth [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClientId { get; set; } + /// + /// The client secret in plain text. Deprecated: anyone who can read the Instance can read it. Use + /// . + /// [JsonPropertyName("clientSecret")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ClientSecret { get; set; } + /// + /// A key of a Secret in the Instance's namespace holding the client secret. + /// + [JsonPropertyName("clientSecretRef")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public V1SecretKeySelector? ClientSecretRef { get; set; } + [JsonPropertyName("scopes")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string[]? Scopes { get; set; } diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs index 10654c4..4d0f3e5 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs @@ -135,8 +135,9 @@ protected override async Task Reconcile(V1alpha1Instance insta info = await GetInfoAsync(api, cancellationToken); } - // retrieve and copy applied settings to status - instance.Status.Info = info; + // retrieve and copy applied settings to status; comparisons above use what Seq returns, never status, so + // secrets can be redacted here without causing them to be rewritten on every reconcile + instance.Status.Info = RedactSecrets(info); instance = await Kube.UpdateStatusAsync(instance, cancellationToken); return instance; } @@ -936,6 +937,49 @@ async Task GetAuthSettingsAsync(SeqConnection api, InstanceConfAuthenticationSpe /// /// /// + /// + /// Resolves a secret setting given either in plain text or as a reference to a Secret in the Instance's namespace. + /// Returns null when neither is given, or the referenced Secret is optional and missing. + /// + /// + /// + /// + /// + /// + /// + async Task ResolveSecretSettingAsync(V1alpha1Instance instance, string name, string? value, V1SecretKeySelector? valueRef, CancellationToken cancellationToken) + { + if (value is not null && valueRef is not null) + throw new InvalidOperationException($"{EntityTypeName} {instance.Namespace()}/{instance.Name()} is invalid: set only one of {name} and {name}Ref."); + + if (valueRef is null) + return value; + + return await Lookup.ResolveSecretKeySelectorAsync(valueRef, instance.Namespace(), cancellationToken) is { } buf ? Encoding.UTF8.GetString(buf) : null; + } + + /// + /// Value shown in status in place of a secret setting that is set. + /// + internal const string RedactedValue = ""; + + /// + /// Replaces the secret settings read back from Seq with , so that status shows whether + /// they are set without exposing them to anyone who can read the Instance. + /// + /// + /// + internal static InstanceInfo RedactSecrets(InstanceInfo info) + { + if (info.Auth?.Entra is { ClientKey: { Length: > 0 } } entra) + entra.ClientKey = RedactedValue; + + if (info.Auth?.Oidc is { ClientSecret: { Length: > 0 } } oidc) + oidc.ClientSecret = RedactedValue; + + return info; + } + async Task PutSettingValueAsync(V1alpha1Instance instance, SeqConnection api, SettingName name, T? value, CancellationToken cancellationToken) { Logger.LogDebug("{EntityTypeName} {EntityNamespace}/{EntityName} putting Seq setting {SettingName}.", EntityTypeName, instance.Namespace(), instance.Name(), name); @@ -1128,9 +1172,9 @@ async Task PutAuthSettingsAsync(V1alpha1Instance instance, SeqConnection api, In if (info?.Entra?.ClientId != entra.ClientId) await PutSettingValueAsync(instance, api, SettingName.EntraIDClientId, entra.ClientId, cancellationToken); - if (entra.ClientKey is not null) - if (info?.Entra?.ClientKey != entra.ClientKey) - await PutSettingValueAsync(instance, api, SettingName.EntraIDClientKey, entra.ClientKey, cancellationToken); + if (await ResolveSecretSettingAsync(instance, "entra.clientKey", entra.ClientKey, entra.ClientKeyRef, cancellationToken) is { } clientKey) + if (info?.Entra?.ClientKey != clientKey) + await PutSettingValueAsync(instance, api, SettingName.EntraIDClientKey, clientKey, cancellationToken); if (conf.AutomaticallyProvisionAuthenticatedUsers is not null) if (info?.AutomaticallyProvisionAuthenticatedUsers != conf.AutomaticallyProvisionAuthenticatedUsers) @@ -1155,9 +1199,9 @@ async Task PutAuthSettingsAsync(V1alpha1Instance instance, SeqConnection api, In if (info?.Oidc?.ClientId != oidc.ClientId) await PutSettingValueAsync(instance, api, SettingName.OpenIdConnectClientId, oidc.ClientId, cancellationToken); - if (oidc.ClientSecret is not null) - if (info?.Oidc?.ClientSecret != oidc.ClientSecret) - await PutSettingValueAsync(instance, api, SettingName.OpenIdConnectClientSecret, oidc.ClientSecret, cancellationToken); + if (await ResolveSecretSettingAsync(instance, "oidc.clientSecret", oidc.ClientSecret, oidc.ClientSecretRef, cancellationToken) is { } clientSecret) + if (info?.Oidc?.ClientSecret != clientSecret) + await PutSettingValueAsync(instance, api, SettingName.OpenIdConnectClientSecret, clientSecret, cancellationToken); if (oidc.MetadataAddress is not null) if (info?.Oidc?.MetadataAddress != oidc.MetadataAddress) From ca0fe8ce16148d69639910bef0013b3303eda09b Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:04:29 -0500 Subject: [PATCH 4/7] Fix envFrom, default Signal permissions, duplicate creates, stuck deletions and cross-namespace Secrets - deployment.envFrom threw on every reconcile: the container's EnvFrom list was never created. - The default Instance permissions omitted Signals, so a Signal in the Instance's own namespace was refused unless spec.permissions was set. - If recording a newly created object's ID failed (the status write, or an ApiKey's token Secret), the retry created it again, since most kinds cannot be found in Seq without the ID. The new object is now removed before the error surfaces. - Deleting an entity whose Instance was gone, or being deleted and unreachable, held its finalizer forever, and with it any namespace being deleted. It is now released, leaving the object in Seq; nothing could reach Seq to remove it without the Instance anyway. - Secret references naming another namespace were followed with the operator's cluster-wide access, letting anyone able to create an Instance read any Secret through remote.auth. They are now refused. --- README.md | 25 +++--- .../InstancePermissionTests.cs | 46 ++++++++++ .../SecretReferenceTests.cs | 36 ++++++++ .../ApiKey/V1alpha1ApiKeyController.cs | 15 +++- .../Instance/V1alpha1Instance.cs | 7 +- .../Instance/V1alpha1InstanceController.cs | 2 + src/Alethic.Seq.Operator/LookupService.cs | 28 ++++-- .../V1alpha1InstanceEntityController.cs | 89 +++++++++++++++---- 8 files changed, 213 insertions(+), 35 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs create mode 100644 src/Alethic.Seq.Operator.Tests/SecretReferenceTests.cs diff --git a/README.md b/README.md index 1957a81..cbde149 100644 --- a/README.md +++ b/README.md @@ -253,9 +253,6 @@ env: ### Signals and retention policies -The Instance must grant `signals` permission for a Signal to attach; see -[Which namespaces may use an Instance](#which-namespaces-may-use-an-instance). - ```yaml apiVersion: seq.k8s.datalust.co/v1alpha1 kind: Signal @@ -292,9 +289,8 @@ spec: ### Which namespaces may use an Instance -By default an Instance only accepts ApiKeys, RetentionPolicies and Alerts from its own namespace. Signals are not -in the defaults: an Instance accepts them only with an explicit `signals` permission. To change any of this, set -`spec.permissions`. +By default an Instance accepts ApiKeys, Signals, RetentionPolicies and Alerts from its own namespace only. To change +this, set `spec.permissions`. `spec.permissions` is a list of entries. For each resource, the first entry whose `namespaces` matches the resource's namespace decides what it may do. `from` is one of `Same` (the default), `All`, `Selector` or `None`. @@ -357,8 +353,12 @@ take that away. ### Deletion Each resource carries a finalizer. Deleting an ApiKey, Signal, RetentionPolicy or Alert deletes the matching -object in Seq, provided the operator can still reach the Instance. If it can't, the resource stays in -`Terminating` until the Instance is reachable again or the finalizer is removed by hand. +object in Seq. If Seq can't be reached, the operator keeps retrying, so a brief outage doesn't leave objects +behind in Seq. + +If the Instance itself is gone or being deleted, the operator tries once and then lets the resource go, leaving the +object in Seq. Without the Instance it has no way to reach Seq, and holding on would leave the resource, and any +namespace being deleted with it, stuck in `Terminating`. A deployed Instance takes its Seq server with it anyway. ## Upgrading @@ -372,6 +372,13 @@ Before upgrading, find ApiKeys that ask for more than `Ingest` or `Public` from and grant what they need in the Instance's `spec.permissions`. Otherwise they will be refused from then on. Their keys in Seq keep working; they just stop being updated. Instances without `spec.permissions` are unaffected. +### Secret references must stay in their own namespace + +A Secret reference with a `namespace` other than the resource's own is now refused. This covers an Instance's +`remote.auth[].login.secretRef` and `remote.auth[].token.secretRef`, and an ApiKey's `secretRef`. Previously the +operator followed them using its cluster-wide access. Copy any such Secret into the resource's namespace before +upgrading. + ### SSO secrets move to Secrets Earlier versions copied the OIDC client secret and Entra client key into each Instance's status, where anyone with @@ -416,8 +423,6 @@ Fresh installs don't need this. doesn't allow a StatefulSet's volume claims to change. - **Namespace selectors** support `matchLabels` but not `matchExpressions`. - **Restricting the operator to one namespace** is not supported yet; it always watches the whole cluster. -- **Secrets in other namespaces**: an Instance's `remote.auth` can name a Secret in any namespace. In a - multi-tenant cluster, restrict who can create Instances. ## Building diff --git a/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs b/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs new file mode 100644 index 0000000..b76b317 --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs @@ -0,0 +1,46 @@ +using Alethic.Seq.Operator.Instance; + +using k8s.Models; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Covers the permissions an Instance grants when it does not set spec.permissions. + /// + [TestClass] + public class InstancePermissionTests + { + + static readonly V1alpha1Instance Instance = new() + { + Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, + Spec = new V1alpha1InstanceSpec(), + }; + + static V1Namespace Namespace(string name) => new() { Metadata = new V1ObjectMeta() { Name = name } }; + + [TestMethod] + public void DefaultsAllowEveryKindInTheSameNamespace() + { + var ns = Namespace("seq"); + Assert.IsTrue(Instance.CheckPermission(ns, false, p => p.ApiKeys?.Create)); + Assert.IsTrue(Instance.CheckPermission(ns, false, p => p.Alerts?.Create)); + Assert.IsTrue(Instance.CheckPermission(ns, false, p => p.RetentionPolicies?.Create)); + Assert.IsTrue(Instance.CheckPermission(ns, false, p => p.Signals?.Create)); + Assert.IsTrue(Instance.CheckPermission(ns, false, p => p.Signals?.Attach)); + } + + [TestMethod] + public void DefaultsAllowNothingFromOtherNamespaces() + { + var ns = Namespace("app"); + Assert.IsFalse(Instance.CheckPermission(ns, false, p => p.ApiKeys?.Create)); + Assert.IsFalse(Instance.CheckPermission(ns, false, p => p.Signals?.Create)); + } + + } + +} diff --git a/src/Alethic.Seq.Operator.Tests/SecretReferenceTests.cs b/src/Alethic.Seq.Operator.Tests/SecretReferenceTests.cs new file mode 100644 index 0000000..816812f --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/SecretReferenceTests.cs @@ -0,0 +1,36 @@ +using k8s.Models; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Covers the rule that a Secret reference may only name the namespace of the resource holding it. + /// + [TestClass] + public class SecretReferenceTests + { + + [TestMethod] + public void NoNamespaceIsLocal() + { + Assert.IsFalse(LookupService.IsCrossNamespace(new V1SecretReference() { Name = "login" }, "seq")); + Assert.IsFalse(LookupService.IsCrossNamespace(new V1SecretReference() { Name = "login", NamespaceProperty = "" }, "seq")); + } + + [TestMethod] + public void SameNamespaceIsLocal() + { + Assert.IsFalse(LookupService.IsCrossNamespace(new V1SecretReference() { Name = "login", NamespaceProperty = "seq" }, "seq")); + } + + [TestMethod] + public void OtherNamespaceIsRefused() + { + Assert.IsTrue(LookupService.IsCrossNamespace(new V1SecretReference() { Name = "login", NamespaceProperty = "other-team" }, "seq")); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs index f91836a..066186c 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs @@ -230,7 +230,7 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a // entity specifies an existing secret, this may be a token source if (entity.Spec.SecretRef is not null) { - var secret = await Lookup.ResolveSecretRefAsync(entity.Spec.SecretRef, entity.Spec.SecretRef.NamespaceProperty ?? defaultNamespace, cancellationToken); + var secret = await Lookup.ResolveSecretRefAsync(entity.Spec.SecretRef, entity.Namespace(), cancellationToken); if (secret is not null) if (secret.Data.TryGetValue(SECRET_TOKEN_KEY_NAME, out var tokenBuf)) create.Token = Encoding.UTF8.GetString(tokenBuf); @@ -239,8 +239,17 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a // submit the new key to the API var self = await api.ApiKeys.AddAsync(create, cancellationToken); - // update the secret with the endpoint and the token which may be from the server - await ApplySecretAsync(entity, api.Client.ServerUrl, EmptyToNull(self.Token) ?? EmptyToNull(create.Token), defaultNamespace, cancellationToken); + // update the secret with the endpoint and the token which may be from the server; Seq only returns the token + // now, so a key whose token could not be stored is useless and is removed rather than left to be duplicated + try + { + await ApplySecretAsync(entity, api.Client.ServerUrl, EmptyToNull(self.Token) ?? EmptyToNull(create.Token), defaultNamespace, cancellationToken); + } + catch (Exception e) + { + await DeleteOrphanAsync(instance, entity, api, self.Id, e, cancellationToken); + throw; + } // newly created ID return self.Id; diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs b/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs index f4e1cc1..558fa98 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs @@ -52,7 +52,12 @@ public partial class V1alpha1Instance : { Attach = true, Create = true, - } + }, + Signals = new InstanceSignalPermissions() + { + Attach = true, + Create = true, + }, }; /// diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs index 4d0f3e5..b4abc3c 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs @@ -592,6 +592,8 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDepl foreach (var i in env) container.Env.Add(i); + container.EnvFrom ??= new List(); + container.EnvFrom.Clear(); if (deployment.EnvFrom is { Count: > 0 } envFrom) foreach (var i in envFrom) container.EnvFrom.Add(i); diff --git a/src/Alethic.Seq.Operator/LookupService.cs b/src/Alethic.Seq.Operator/LookupService.cs index b1d0c2f..a7ed66d 100644 --- a/src/Alethic.Seq.Operator/LookupService.cs +++ b/src/Alethic.Seq.Operator/LookupService.cs @@ -69,13 +69,14 @@ public LookupService(IKubernetesClient kube, IMemoryCache cache) } /// - /// Attempts to resolve the secret document referenced by the secret reference. + /// Attempts to resolve the secret document referenced by the secret reference, which must be in the namespace of + /// the resource holding the reference. /// /// - /// + /// The namespace of the resource holding the reference. /// /// - public async Task ResolveSecretRefAsync(V1SecretReference? secretRef, string defaultNamespace, CancellationToken cancellationToken) + public async Task ResolveSecretRefAsync(V1SecretReference? secretRef, string ownerNamespace, CancellationToken cancellationToken) { if (secretRef is null) return null; @@ -83,11 +84,26 @@ public LookupService(IKubernetesClient kube, IMemoryCache cache) if (string.IsNullOrWhiteSpace(secretRef.Name)) throw new InvalidOperationException($"Secret reference {secretRef} has no name."); - var ns = secretRef.NamespaceProperty ?? defaultNamespace; - if (string.IsNullOrWhiteSpace(ns)) + if (string.IsNullOrWhiteSpace(ownerNamespace)) throw new InvalidOperationException($"Secret reference {secretRef} has no discovered namespace."); - return await ResolveSecretAsync(secretRef.Name, ns, cancellationToken); + // The operator can read Secrets in every namespace, so following a reference into another namespace would let + // anyone able to create a resource here read any Secret in the cluster through it. + if (IsCrossNamespace(secretRef, ownerNamespace)) + throw new InvalidOperationException($"Secret reference {secretRef.Name} names namespace {secretRef.NamespaceProperty}, but Secrets must be in the namespace of the resource referencing them, {ownerNamespace}."); + + return await ResolveSecretAsync(secretRef.Name, ownerNamespace, cancellationToken); + } + + /// + /// Returns whether the secret reference names a namespace other than . + /// + /// + /// + /// + internal static bool IsCrossNamespace(V1SecretReference secretRef, string ownerNamespace) + { + return string.IsNullOrEmpty(secretRef.NamespaceProperty) == false && secretRef.NamespaceProperty != ownerNamespace; } /// diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs b/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs index 91c1072..b6d0417 100644 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs +++ b/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs @@ -131,6 +131,31 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// protected abstract Task UpdateAsync(V1alpha1Instance instance, TEntity entity, SeqConnection api, string id, TInfo? info, TConf? conf, string defaultNamespace, CancellationToken cancellationToken); + /// + /// Removes an object just created in Seq whose ID could not be recorded, so that the retry does not leave it + /// behind as a duplicate. Failure to remove it is logged and swallowed, so the original error is what surfaces. + /// + /// + /// + /// + /// + /// + /// + /// + protected async Task DeleteOrphanAsync(V1alpha1Instance instance, TEntity entity, SeqConnection api, string id, Exception cause, CancellationToken cancellationToken) + { + Logger.LogWarning(cause, "{EntityTypeName} {Namespace}/{Name} created {Id} in Seq but could not record it; removing it so the retry does not create a duplicate.", EntityTypeName, entity.Namespace(), entity.Name(), id); + + try + { + await DeleteAsync(instance, api, id, cancellationToken); + } + catch (Exception e) + { + Logger.LogError(e, "{EntityTypeName} {Namespace}/{Name} could not remove {Id} from Seq; it is now orphaned and must be removed by hand.", EntityTypeName, entity.Namespace(), entity.Name(), id); + } + } + /// protected override async Task Reconcile(TEntity entity, CancellationToken cancellationToken) { @@ -169,8 +194,21 @@ protected override async Task Reconcile(TEntity entity, CancellationTok throw new InvalidOperationException($"{EntityTypeName} {entity.Namespace()}/{entity.Name()} is invalid: {msg}"); // create new entity and associate - entity.Status.Id = await CreateAsync(instance, entity, api, entity.Spec.Conf, entity.Namespace(), cancellationToken); - Logger.LogInformation("{EntityTypeName} {Namespace}/{Name} created with {Id}", EntityTypeName, entity.Namespace(), entity.Name(), entity.Status.Id); + var id = await CreateAsync(instance, entity, api, entity.Spec.Conf, entity.Namespace(), cancellationToken); + Logger.LogInformation("{EntityTypeName} {Namespace}/{Name} created with {Id}", EntityTypeName, entity.Namespace(), entity.Name(), id); + + // the ID is the only link to what was just created, and most kinds cannot be found again without it, + // so save it now; if that fails, remove the object rather than let the retry create a second one + entity.Status.Id = id; + try + { + entity = await Kube.UpdateStatusAsync(entity, cancellationToken); + } + catch (Exception e) + { + await DeleteOrphanAsync(instance, entity, api, id, e, cancellationToken); + throw; + } } else { @@ -179,10 +217,10 @@ protected override async Task Reconcile(TEntity entity, CancellationTok entity.Status.Id = entityId; Logger.LogInformation("{EntityTypeName} {Namespace}/{Name} found with {Id}", EntityTypeName, entity.Namespace(), entity.Name(), entity.Status.Id); - } - // save the status - entity = await Kube.UpdateStatusAsync(entity, cancellationToken); + // save the status + entity = await Kube.UpdateStatusAsync(entity, cancellationToken); + } } // at this point we must have a reference to an entity @@ -245,25 +283,39 @@ protected virtual Task ApplyStatusAsync(TEntity entity, SeqConnection api, TInfo /// public override sealed async Task> DeletedAsync(TEntity entity, CancellationToken cancellationToken) { + // whether the Instance is itself being deleted, in which case removing the object from Seq is best effort + var instanceDeleting = false; + try { - if (entity.Spec.InstanceRef is null) - throw new InvalidOperationException($"{EntityTypeName} {entity.Namespace()}/{entity.Name()} missing a Seq instance reference."); + if (string.IsNullOrWhiteSpace(entity.Status.Id)) + { + Logger.LogWarning("{EntityTypeName} {EntityNamespace}/{EntityName} has no known ID, skipping delete (reason: entity was never successfully created in Seq).", EntityTypeName, entity.Namespace(), entity.Name()); + return ReconciliationResult.Success(entity); + } - var instance = await Lookup.ResolveInstanceRefAsync(entity.Spec.InstanceRef, entity.Namespace(), cancellationToken); + if (entity.Spec.InstanceRef is not { Name: { } instanceName } instanceRef) + { + Logger.LogWarning("{EntityTypeName} {EntityNamespace}/{EntityName} has no Seq instance reference, skipping delete of {Id}.", EntityTypeName, entity.Namespace(), entity.Name(), entity.Status.Id); + return ReconciliationResult.Success(entity); + } + + // read past the lookup cache: a cached Instance may already be gone, or not yet show it is being deleted + var instance = await Kube.GetAsync(instanceName, instanceRef.Namespace ?? entity.Namespace(), cancellationToken: cancellationToken); if (instance is null) - throw new InvalidOperationException($"{EntityTypeName} {entity.Namespace()}/{entity.Name()} could not resolve Seq instance."); + { + // without the Instance there are no credentials to reach Seq with; holding the finalizer would + // only block the delete (and any namespace containing it) forever + Logger.LogWarning("{EntityTypeName} {EntityNamespace}/{EntityName} references Seq instance {InstanceName} which no longer exists, skipping delete of {Id} (it is left in Seq).", EntityTypeName, entity.Namespace(), entity.Name(), instanceName, entity.Status.Id); + return ReconciliationResult.Success(entity); + } + + instanceDeleting = instance.Metadata.DeletionTimestamp is not null; var api = await GetInstanceConnectionAsync(instance, cancellationToken); if (api is null) throw new InvalidOperationException($"{EntityTypeName} {entity.Namespace()}/{entity.Name()} failed to retrieve API client."); - if (string.IsNullOrWhiteSpace(entity.Status.Id)) - { - Logger.LogWarning("{EntityTypeName} {EntityNamespace}/{EntityName} has no known ID, skipping delete (reason: entity was never successfully created in Seq).", EntityTypeName, entity.Namespace(), entity.Name()); - return ReconciliationResult.Success(entity); - } - var self = await GetAsync(entity, api, entity.Status.Id, entity.Namespace(), cancellationToken); if (self is null) { @@ -276,6 +328,13 @@ public override sealed async Task> DeletedAsync(TE Logger.LogInformation("{EntityTypeName} {Namespace}/{Name} deletion completed successfully", EntityTypeName, entity.Namespace(), entity.Name()); return ReconciliationResult.Success(entity); } + catch (Exception e) when (instanceDeleting) + { + // the Instance is going too, and a deployed one takes its Seq server with it; an unreachable Seq is no + // reason to hold this entity, or the namespace both are in, in Terminating + Logger.LogWarning(e, "{EntityTypeName} {EntityNamespace}/{EntityName} could not remove {Id} from Seq while its Seq instance is being deleted, skipping delete (it is left in Seq).", EntityTypeName, entity.Namespace(), entity.Name(), entity.Status.Id); + return ReconciliationResult.Success(entity); + } catch (RetryException e) { try From f0806e926e27316ac6e57bc794170056697c733d Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:08:33 -0500 Subject: [PATCH 5/7] Introduce v1 as the storage version, keeping v1alpha1 served and deprecated Every kind is now served at seq.k8s.datalust.co/v1 with exactly the v1alpha1 schema, and v1 is the storage version. The v1alpha1 entity types share the v1 spec and status types and declare nothing of their own, so the API server converts between the versions by relabelling apiVersion (conversion: None) and no webhook is needed. The operator works in v1 throughout. The chart marks every non-storage version deprecated, with a warning naming its replacement, since KubeOps has no attribute for it. A server-side dry run of the resulting CRDs against a live cluster still storing v1alpha1 is accepted. Classes are renamed from V1alpha1* to V1*, except the legacy finalizer shims, whose class names are their finalizer identifiers. V1alpha1Condition becomes V1EntityCondition, since V1Condition is taken by the Kubernetes client. --- README.md | 40 +++-- charts/seq-operator/seq-operator.msbuildproj | 22 +++ .../ApiKeyPermissionTests.cs | 14 +- .../ApiVersionTests.cs | 80 +++++++++ .../EntityFinalizersTests.cs | 20 +-- .../InstancePermissionTests.cs | 4 +- .../InstanceStatusRedactionTests.cs | 12 +- .../PasswordGeneratorTests.cs | 10 +- src/Alethic.Seq.Operator/Alerts/V1Alert.cs | 20 +++ ...lertController.cs => V1AlertController.cs} | 28 ++-- .../{V1alpha1AlertSpec.cs => V1AlertSpec.cs} | 4 +- ...1alpha1AlertStatus.cs => V1AlertStatus.cs} | 4 +- .../Alerts/V1alpha1Alert.cs | 14 +- src/Alethic.Seq.Operator/ApiKey/V1ApiKey.cs | 20 +++ ...KeyController.cs => V1ApiKeyController.cs} | 42 ++--- ...{V1alpha1ApiKeySpec.cs => V1ApiKeySpec.cs} | 4 +- ...lpha1ApiKeyStatus.cs => V1ApiKeyStatus.cs} | 4 +- .../ApiKey/V1alpha1ApiKey.cs | 16 +- .../Finalizers/AlertFinalizer.cs | 6 +- .../Finalizers/ApiKeyFinalizer.cs | 6 +- .../Finalizers/EntityFinalizers.cs | 12 +- .../Finalizers/InstanceFinalizer.cs | 6 +- .../Finalizers/Legacy/LegacyFinalizers.cs | 30 ++-- .../Finalizers/RetentionPolicyFinalizer.cs | 6 +- .../Finalizers/SignalFinalizer.cs | 6 +- .../Instance/V1Instance.cs | 154 ++++++++++++++++++ ...eController.cs => V1InstanceController.cs} | 62 +++---- ...lpha1InstanceSpec.cs => V1InstanceSpec.cs} | 2 +- ...1InstanceStatus.cs => V1InstanceStatus.cs} | 4 +- .../Instance/V1alpha1Instance.cs | 150 ++--------------- src/Alethic.Seq.Operator/LookupService.cs | 6 +- src/Alethic.Seq.Operator/Program.cs | 2 +- .../RetentionPolicy/V1RetentionPolicy.cs | 20 +++ ...ller.cs => V1RetentionPolicyController.cs} | 26 +-- ...PolicySpec.cs => V1RetentionPolicySpec.cs} | 4 +- ...cyStatus.cs => V1RetentionPolicyStatus.cs} | 4 +- .../V1alpha1RetentionPolicy.cs | 14 +- src/Alethic.Seq.Operator/Signals/V1Signal.cs | 20 +++ ...nalController.cs => V1SignalController.cs} | 28 ++-- ...{V1alpha1SignalSpec.cs => V1SignalSpec.cs} | 4 +- ...lpha1SignalStatus.cs => V1SignalStatus.cs} | 4 +- .../Signals/V1alpha1Signal.cs | 14 +- ...{V1alpha1Controller.cs => V1Controller.cs} | 34 ++-- .../{V1alpha1Entity.cs => V1Entity.cs} | 6 +- ...lpha1Condition.cs => V1EntityCondition.cs} | 2 +- ...{V1alpha1EntitySpec.cs => V1EntitySpec.cs} | 2 +- ...lpha1EntityStatus.cs => V1EntityStatus.cs} | 4 +- src/Alethic.Seq.Operator/V1InstanceEntity.cs | 13 ++ ...oller.cs => V1InstanceEntityController.cs} | 30 ++-- .../V1InstanceEntitySpec.cs | 12 ++ ...ityStatus.cs => V1InstanceEntityStatus.cs} | 2 +- ...nceReference.cs => V1InstanceReference.cs} | 2 +- .../V1alpha1InstanceEntity.cs | 13 -- .../V1alpha1InstanceEntitySpec.cs | 12 -- 54 files changed, 669 insertions(+), 411 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/ApiVersionTests.cs create mode 100644 src/Alethic.Seq.Operator/Alerts/V1Alert.cs rename src/Alethic.Seq.Operator/Alerts/{V1alpha1AlertController.cs => V1AlertController.cs} (73%) rename src/Alethic.Seq.Operator/Alerts/{V1alpha1AlertSpec.cs => V1AlertSpec.cs} (68%) rename src/Alethic.Seq.Operator/Alerts/{V1alpha1AlertStatus.cs => V1AlertStatus.cs} (65%) create mode 100644 src/Alethic.Seq.Operator/ApiKey/V1ApiKey.cs rename src/Alethic.Seq.Operator/ApiKey/{V1alpha1ApiKeyController.cs => V1ApiKeyController.cs} (90%) rename src/Alethic.Seq.Operator/ApiKey/{V1alpha1ApiKeySpec.cs => V1ApiKeySpec.cs} (79%) rename src/Alethic.Seq.Operator/ApiKey/{V1alpha1ApiKeyStatus.cs => V1ApiKeyStatus.cs} (71%) create mode 100644 src/Alethic.Seq.Operator/Instance/V1Instance.cs rename src/Alethic.Seq.Operator/Instance/{V1alpha1InstanceController.cs => V1InstanceController.cs} (94%) rename src/Alethic.Seq.Operator/Instance/{V1alpha1InstanceSpec.cs => V1InstanceSpec.cs} (92%) rename src/Alethic.Seq.Operator/Instance/{V1alpha1InstanceStatus.cs => V1InstanceStatus.cs} (72%) create mode 100644 src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicy.cs rename src/Alethic.Seq.Operator/RetentionPolicy/{V1alpha1RetentionPolicyController.cs => V1RetentionPolicyController.cs} (67%) rename src/Alethic.Seq.Operator/RetentionPolicy/{V1alpha1RetentionPolicySpec.cs => V1RetentionPolicySpec.cs} (66%) rename src/Alethic.Seq.Operator/RetentionPolicy/{V1alpha1RetentionPolicyStatus.cs => V1RetentionPolicyStatus.cs} (63%) create mode 100644 src/Alethic.Seq.Operator/Signals/V1Signal.cs rename src/Alethic.Seq.Operator/Signals/{V1alpha1SignalController.cs => V1SignalController.cs} (83%) rename src/Alethic.Seq.Operator/Signals/{V1alpha1SignalSpec.cs => V1SignalSpec.cs} (68%) rename src/Alethic.Seq.Operator/Signals/{V1alpha1SignalStatus.cs => V1SignalStatus.cs} (64%) rename src/Alethic.Seq.Operator/{V1alpha1Controller.cs => V1Controller.cs} (94%) rename src/Alethic.Seq.Operator/{V1alpha1Entity.cs => V1Entity.cs} (68%) rename src/Alethic.Seq.Operator/{V1alpha1Condition.cs => V1EntityCondition.cs} (95%) rename src/Alethic.Seq.Operator/{V1alpha1EntitySpec.cs => V1EntitySpec.cs} (82%) rename src/Alethic.Seq.Operator/{V1alpha1EntityStatus.cs => V1EntityStatus.cs} (66%) create mode 100644 src/Alethic.Seq.Operator/V1InstanceEntity.cs rename src/Alethic.Seq.Operator/{V1alpha1InstanceEntityController.cs => V1InstanceEntityController.cs} (90%) create mode 100644 src/Alethic.Seq.Operator/V1InstanceEntitySpec.cs rename src/Alethic.Seq.Operator/{V1alpha1InstanceEntityStatus.cs => V1InstanceEntityStatus.cs} (56%) rename src/Alethic.Seq.Operator/{V1alpha1InstanceReference.cs => V1InstanceReference.cs} (93%) delete mode 100644 src/Alethic.Seq.Operator/V1alpha1InstanceEntity.cs delete mode 100644 src/Alethic.Seq.Operator/V1alpha1InstanceEntitySpec.cs diff --git a/README.md b/README.md index cbde149..0b2ecd5 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,8 @@ It can: - **Hand API keys to workloads** as Kubernetes Secrets containing the Seq endpoint and token. > [!NOTE] -> The resources are at `v1alpha1`. They work and are in use, but their shape may still change between releases. -> See [Limitations](#limitations). +> The resources are at `v1`, and their shape is now stable. `v1alpha1` is still served, with the same schema, for +> existing manifests; see [Moving to v1](#moving-to-v1). ## Installation @@ -47,15 +47,15 @@ See [`values.yaml`](charts/seq-operator/values.yaml) for scheduling, security co ## Resources -All resources are in the `seq.k8s.datalust.co/v1alpha1` API group. +All resources are in the `seq.k8s.datalust.co/v1` API group. | kind | short name | manages | | --- | --- | --- | | `Instance` | `seqinstance` | A Seq server: deployed by the operator, or an existing one it connects to. Holds server settings. | | `ApiKey` | `seqapikey` | A Seq API key, published to a Kubernetes Secret. | | `Signal` | `seqsignal` | A shared Seq signal. | -| `RetentionPolicy` | | A Seq retention policy. | -| `Alert` | | A Seq alert (title only; see [Limitations](#limitations)). | +| `RetentionPolicy` | `seqretentionpolicy` | A Seq retention policy. | +| `Alert` | `seqalert` | A Seq alert (title only; see [Limitations](#limitations)). | Everything other than `Instance` points at an Instance with `spec.instanceRef`, and puts the settings it manages under `spec.conf`. @@ -65,7 +65,7 @@ manages under `spec.conf`. An Instance without a `remote` section is deployed by the operator. This is the smallest one: ```yaml -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: Instance metadata: name: seq @@ -89,7 +89,7 @@ The `deployment` section adjusts the deployment. It accepts the usual pod settin probe types, `annotations` and `labels`. It also takes `persistence` and `service`: ```yaml -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: Instance metadata: name: seq @@ -135,7 +135,7 @@ stringData: password: "" firstRun: "" --- -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: Instance metadata: name: seq @@ -153,7 +153,7 @@ spec: secretRef: name: seq-login --- -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: ApiKey metadata: name: seq-management @@ -216,7 +216,7 @@ An ApiKey creates a Seq API key and writes it to a Secret with two keys: `endpoi the ApiKey's namespace. ```yaml -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: ApiKey metadata: name: orders @@ -254,7 +254,7 @@ env: ### Signals and retention policies ```yaml -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: Signal metadata: name: errors @@ -273,7 +273,7 @@ spec: columns: - expression: Application --- -apiVersion: seq.k8s.datalust.co/v1alpha1 +apiVersion: seq.k8s.datalust.co/v1 kind: RetentionPolicy metadata: name: thirty-days @@ -362,6 +362,22 @@ namespace being deleted with it, stuck in `Terminating`. A deployed Instance tak ## Upgrading +### Moving to v1 + +The resources are now at `seq.k8s.datalust.co/v1`. `v1` has exactly the same schema as `v1alpha1`, so nothing about +a resource changes except its `apiVersion`: + +- Both versions are served. Existing `v1alpha1` manifests keep working, and every object can be read and written + through either version. The Kubernetes API server converts between them itself; there is no conversion webhook. +- `v1` is the storage version. Objects are stored as `v1` the next time they are written. Objects stored before + stay as `v1alpha1` until then, which is harmless. +- `v1alpha1` is deprecated. `kubectl` prints a warning whenever it's used. Change `apiVersion` in your manifests to + `seq.k8s.datalust.co/v1` at your convenience. + +Before `v1alpha1` can ever be removed, every object still stored as `v1alpha1` will have to be rewritten as `v1`, +for example with `kubectl get -A -o yaml | kubectl replace -f -`, so that the CRDs can stop listing it +among their stored versions. + ### ApiKey permission flags are enforced Earlier versions accepted the `apiKeys.set*` flags but never checked them, so any namespace allowed to create diff --git a/charts/seq-operator/seq-operator.msbuildproj b/charts/seq-operator/seq-operator.msbuildproj index 9e08d7d..43b1fc8 100644 --- a/charts/seq-operator/seq-operator.msbuildproj +++ b/charts/seq-operator/seq-operator.msbuildproj @@ -63,6 +63,28 @@ metadataMapping.Children[new YamlScalarNode("labels")] = new YamlScalarNode(@"{{- include ""crd.labels"" . | nindent 4 }}") { Style = ScalarStyle.ForcePlain }; metadataMapping.Children[new YamlScalarNode("annotations")] = new YamlScalarNode(@"{{- include ""crd.annotations"" . | nindent 4 }}") { Style = ScalarStyle.ForcePlain }; } + + // every version other than the storage version is still served for existing clients but + // deprecated, so the API server warns whoever uses it; KubeOps has no attribute for this + if (rootMapping.Children.TryGetValue(new YamlScalarNode("spec"), out var spec) && spec is YamlMappingNode specMapping && + specMapping.Children.TryGetValue(new YamlScalarNode("versions"), out var versions) && versions is YamlSequenceNode versionsSequence) + { + var group = specMapping.Children.TryGetValue(new YamlScalarNode("group"), out var g) ? g.ToString() : ""; + var kind = specMapping.Children.TryGetValue(new YamlScalarNode("names"), out var n) && n is YamlMappingNode nm && nm.Children.TryGetValue(new YamlScalarNode("kind"), out var k) ? k.ToString() : ""; + var storage = ""; + foreach (var v in versionsSequence.Children) + if (v is YamlMappingNode vm && vm.Children.TryGetValue(new YamlScalarNode("storage"), out var s) && s.ToString() == "true") + storage = vm.Children[new YamlScalarNode("name")].ToString(); + + foreach (var v in versionsSequence.Children) + { + if (v is YamlMappingNode vm && vm.Children[new YamlScalarNode("name")].ToString() is var name && name != storage) + { + vm.Children[new YamlScalarNode("deprecated")] = new YamlScalarNode("true") { Style = ScalarStyle.Plain }; + vm.Children[new YamlScalarNode("deprecationWarning")] = new YamlScalarNode($"{group}/{name} {kind} is deprecated; use {group}/{storage} {kind}") { Style = ScalarStyle.DoubleQuoted }; + } + } + } } } diff --git a/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs b/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs index 508833b..c279499 100644 --- a/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs +++ b/src/Alethic.Seq.Operator.Tests/ApiKeyPermissionTests.cs @@ -28,10 +28,10 @@ public class ApiKeyPermissionTests ApiKeyPermission.Organization, ]; - static V1alpha1Instance Instance(params InstancePermission[]? permissions) => new() + static V1Instance Instance(params InstancePermission[]? permissions) => new() { Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, - Spec = new V1alpha1InstanceSpec() { Permissions = permissions }, + Spec = new V1InstanceSpec() { Permissions = permissions }, }; static V1Namespace Namespace(string name, IDictionary? labels = null) => new() @@ -45,8 +45,8 @@ public class ApiKeyPermissionTests ApiKeys = apiKeys, }; - static List Denied(V1alpha1Instance instance, string ns, params ApiKeyPermission[] permissions) => - V1alpha1ApiKeyController.GetDeniedPermissions(instance, Namespace(ns), permissions); + static List Denied(V1Instance instance, string ns, params ApiKeyPermission[] permissions) => + V1ApiKeyController.GetDeniedPermissions(instance, Namespace(ns), permissions); [TestMethod] public void IngestAndPublicAreAllowedWhenNotMentioned() @@ -109,7 +109,7 @@ public void SelectorScopesTheGrant() ApiKeys = new InstanceApiKeyPermissions() { Create = true, SetRead = true }, }); - var labelled = V1alpha1ApiKeyController.GetDeniedPermissions(instance, Namespace("ops", new Dictionary() { ["seq-read"] = "true" }), [ApiKeyPermission.Read]); + var labelled = V1ApiKeyController.GetDeniedPermissions(instance, Namespace("ops", new Dictionary() { ["seq-read"] = "true" }), [ApiKeyPermission.Read]); CollectionAssert.AreEqual(Array.Empty(), labelled); CollectionAssert.AreEqual(new[] { ApiKeyPermission.Read }, Denied(instance, "app", ApiKeyPermission.Read)); } @@ -135,8 +135,8 @@ public void UndefinedIsAlwaysDenied() [TestMethod] public void SetupRequiresProjectAndSystem() { - Assert.IsFalse(V1alpha1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true }, ApiKeyPermission.Setup)); - Assert.IsTrue(V1alpha1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true, SetSystem = true }, ApiKeyPermission.Setup)); + Assert.IsFalse(V1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true }, ApiKeyPermission.Setup)); + Assert.IsTrue(V1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true, SetSystem = true }, ApiKeyPermission.Setup)); } #pragma warning restore CS0618 diff --git a/src/Alethic.Seq.Operator.Tests/ApiVersionTests.cs b/src/Alethic.Seq.Operator.Tests/ApiVersionTests.cs new file mode 100644 index 0000000..3a63c70 --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/ApiVersionTests.cs @@ -0,0 +1,80 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; + +using Alethic.Seq.Operator.Alerts; +using Alethic.Seq.Operator.ApiKey; +using Alethic.Seq.Operator.Instance; +using Alethic.Seq.Operator.RetentionPolicy; +using Alethic.Seq.Operator.Signals; + +using k8s.Models; + +using KubeOps.Abstractions.Entities.Attributes; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Pins how each kind is versioned. Both versions are served from one CustomResourceDefinition with + /// conversion: None, which is only safe while their schemas are identical. + /// + [TestClass] + public class ApiVersionTests + { + + static IEnumerable<(Type V1, Type V1alpha1)> Kinds() + { + yield return (typeof(V1Alert), typeof(V1alpha1Alert)); + yield return (typeof(V1ApiKey), typeof(V1alpha1ApiKey)); + yield return (typeof(V1Instance), typeof(V1alpha1Instance)); + yield return (typeof(V1RetentionPolicy), typeof(V1alpha1RetentionPolicy)); + yield return (typeof(V1Signal), typeof(V1alpha1Signal)); + } + + static KubernetesEntityAttribute Entity(Type type) => type.GetCustomAttribute()!; + + [TestMethod] + public void V1IsTheStorageVersion() + { + foreach (var (v1, v1alpha1) in Kinds()) + { + Assert.AreEqual("v1", Entity(v1).ApiVersion); + Assert.IsNotNull(v1.GetCustomAttribute(), $"{v1.Name} is not the storage version"); + Assert.IsNull(v1alpha1.GetCustomAttribute(), $"{v1alpha1.Name} is marked as the storage version"); + } + } + + [TestMethod] + public void V1alpha1IsStillServedUnderTheSameKind() + { + foreach (var (v1, v1alpha1) in Kinds()) + { + Assert.AreEqual("v1alpha1", Entity(v1alpha1).ApiVersion); + Assert.AreEqual(Entity(v1).Group, Entity(v1alpha1).Group); + Assert.AreEqual(Entity(v1).Kind, Entity(v1alpha1).Kind); + Assert.AreEqual(Entity(v1).PluralName, Entity(v1alpha1).PluralName); + CollectionAssert.AreEqual(v1.GetCustomAttribute()!.ShortNames, v1alpha1.GetCustomAttribute()!.ShortNames); + } + } + + /// + /// The v1alpha1 types must share v1's spec and status types and add nothing of their own, so that the generated + /// schemas match and objects round-trip between versions without losing fields. + /// + [TestMethod] + public void V1alpha1SharesTheV1Schema() + { + foreach (var (v1, v1alpha1) in Kinds()) + { + CollectionAssert.AreEqual(v1.BaseType!.GetGenericArguments(), v1alpha1.BaseType!.GetGenericArguments(), $"{v1alpha1.Name} has different spec or status types from {v1.Name}"); + Assert.AreEqual(0, v1alpha1.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Length, $"{v1alpha1.Name} declares properties of its own"); + } + } + + } + +} diff --git a/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs b/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs index 2721f3f..4be70f6 100644 --- a/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs +++ b/src/Alethic.Seq.Operator.Tests/EntityFinalizersTests.cs @@ -43,11 +43,11 @@ public class EntityFinalizersTests /// 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"); + yield return (typeof(V1Alert), "seq.k8s.datalust.co/alertfinalizer"); + yield return (typeof(V1ApiKey), "seq.k8s.datalust.co/apikeyfinalizer"); + yield return (typeof(V1Instance), "seq.k8s.datalust.co/instancefinalizer"); + yield return (typeof(V1RetentionPolicy), "seq.k8s.datalust.co/retentionpolicyfinalizer"); + yield return (typeof(V1Signal), "seq.k8s.datalust.co/signalfinalizer"); } /// @@ -56,11 +56,11 @@ public class EntityFinalizersTests /// 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"); + yield return (typeof(V1Alert), "seq.k8s.datalust.co/v1alpha1alertfinalizer"); + yield return (typeof(V1ApiKey), "seq.k8s.datalust.co/v1alpha1apikeyfinalizer"); + yield return (typeof(V1Instance), "seq.k8s.datalust.co/v1alpha1instancefinalizer"); + yield return (typeof(V1RetentionPolicy), "seq.k8s.datalust.co/v1alpha1retentionpolicyfinalizer"); + yield return (typeof(V1Signal), "seq.k8s.datalust.co/v1alpha1signalfinalizer"); } /// diff --git a/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs b/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs index b76b317..af32ed0 100644 --- a/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs +++ b/src/Alethic.Seq.Operator.Tests/InstancePermissionTests.cs @@ -14,10 +14,10 @@ namespace Alethic.Seq.Operator.Tests public class InstancePermissionTests { - static readonly V1alpha1Instance Instance = new() + static readonly V1Instance Instance = new() { Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, - Spec = new V1alpha1InstanceSpec(), + Spec = new V1InstanceSpec(), }; static V1Namespace Namespace(string name) => new() { Metadata = new V1ObjectMeta() { Name = name } }; diff --git a/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs b/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs index ff30a36..029946f 100644 --- a/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs +++ b/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs @@ -24,9 +24,9 @@ public void RedactsEntraClientKey() }, }; - V1alpha1InstanceController.RedactSecrets(info); + V1InstanceController.RedactSecrets(info); - Assert.AreEqual(V1alpha1InstanceController.RedactedValue, info.Auth.Entra.ClientKey); + Assert.AreEqual(V1InstanceController.RedactedValue, info.Auth.Entra.ClientKey); Assert.AreEqual("client", info.Auth.Entra.ClientId); } @@ -41,9 +41,9 @@ public void RedactsOidcClientSecret() }, }; - V1alpha1InstanceController.RedactSecrets(info); + V1InstanceController.RedactSecrets(info); - Assert.AreEqual(V1alpha1InstanceController.RedactedValue, info.Auth.Oidc.ClientSecret); + Assert.AreEqual(V1InstanceController.RedactedValue, info.Auth.Oidc.ClientSecret); Assert.AreEqual("client", info.Auth.Oidc.ClientId); Assert.AreEqual("https://login.example.com", info.Auth.Oidc.Authority); } @@ -62,7 +62,7 @@ public void LeavesUnsetSecretsUnset() }, }; - V1alpha1InstanceController.RedactSecrets(info); + V1InstanceController.RedactSecrets(info); Assert.AreEqual("", info.Auth.Oidc.ClientSecret); } @@ -71,7 +71,7 @@ public void LeavesUnsetSecretsUnset() public void ToleratesNoAuth() { var info = new InstanceInfo(); - Assert.AreSame(info, V1alpha1InstanceController.RedactSecrets(info)); + Assert.AreSame(info, V1InstanceController.RedactSecrets(info)); Assert.IsNull(info.Auth); } diff --git a/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs b/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs index 06d8817..b84ff23 100644 --- a/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs +++ b/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs @@ -20,7 +20,7 @@ public class PasswordGeneratorTests public void GeneratesRequestedLength() { foreach (var length in new[] { 4, 20, 64 }) - Assert.AreEqual(length, V1alpha1InstanceController.GeneratePassword(length).Length); + Assert.AreEqual(length, V1InstanceController.GeneratePassword(length).Length); } [TestMethod] @@ -28,7 +28,7 @@ public void ContainsEveryCharacterClass() { for (var i = 0; i < 1000; i++) { - var password = V1alpha1InstanceController.GeneratePassword(20); + var password = V1InstanceController.GeneratePassword(20); Assert.IsTrue(password.Any(char.IsAsciiLetterLower), password); Assert.IsTrue(password.Any(char.IsAsciiLetterUpper), password); Assert.IsTrue(password.Any(char.IsAsciiDigit), password); @@ -44,7 +44,7 @@ public void FirstCharacterIsNotFixedToOneClass() { var first = new HashSet(); for (var i = 0; i < 1000; i++) - first.Add(char.IsAsciiLetterOrDigit(V1alpha1InstanceController.GeneratePassword(20)[0])); + first.Add(char.IsAsciiLetterOrDigit(V1InstanceController.GeneratePassword(20)[0])); Assert.AreEqual(2, first.Count); } @@ -52,14 +52,14 @@ public void FirstCharacterIsNotFixedToOneClass() [TestMethod] public void DoesNotRepeat() { - var passwords = Enumerable.Range(0, 1000).Select(_ => V1alpha1InstanceController.GeneratePassword(20)).ToHashSet(); + var passwords = Enumerable.Range(0, 1000).Select(_ => V1InstanceController.GeneratePassword(20)).ToHashSet(); Assert.AreEqual(1000, passwords.Count); } [TestMethod] public void RejectsLengthTooShortForEveryClass() { - Assert.ThrowsExactly(() => V1alpha1InstanceController.GeneratePassword(3)); + Assert.ThrowsExactly(() => V1InstanceController.GeneratePassword(3)); } } diff --git a/src/Alethic.Seq.Operator/Alerts/V1Alert.cs b/src/Alethic.Seq.Operator/Alerts/V1Alert.cs new file mode 100644 index 0000000..e27aa47 --- /dev/null +++ b/src/Alethic.Seq.Operator/Alerts/V1Alert.cs @@ -0,0 +1,20 @@ +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace Alethic.Seq.Operator.Alerts +{ + + [EntityScope(EntityScope.Namespaced)] + [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1", Kind = "Alert")] + [StorageVersion] + [KubernetesEntityShortNames("seqalert")] + public partial class V1Alert : + CustomKubernetesEntity, + V1InstanceEntity + { + + } + +} diff --git a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertController.cs b/src/Alethic.Seq.Operator/Alerts/V1AlertController.cs similarity index 73% rename from src/Alethic.Seq.Operator/Alerts/V1alpha1AlertController.cs rename to src/Alethic.Seq.Operator/Alerts/V1AlertController.cs index 4727573..2d5e9b3 100644 --- a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertController.cs +++ b/src/Alethic.Seq.Operator/Alerts/V1AlertController.cs @@ -24,13 +24,13 @@ namespace Alethic.Seq.Operator.Alerts { - [EntityRbac(typeof(V1alpha1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] - [EntityRbac(typeof(V1alpha1Alert), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] + [EntityRbac(typeof(V1Alert), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.All)] [EntityRbac(typeof(Eventsv1Event), Verbs = RbacVerb.All)] - public partial class V1alpha1AlertController : - V1alpha1InstanceEntityController, - IEntityController + public partial class V1AlertController : + V1InstanceEntityController, + IEntityController { /// @@ -41,7 +41,7 @@ public partial class V1alpha1AlertController : /// /// /// - public V1alpha1AlertController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1AlertController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -51,19 +51,19 @@ public V1alpha1AlertController(IKubernetesClient kube, IMemoryCache cache, Looku protected override string EntityTypeName => "Alert"; /// - protected override Task CanAttachFromAsync(V1alpha1Instance instance, V1alpha1Alert entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Alerts?.Attach, cancellationToken); + protected override Task CanAttachFromAsync(V1Instance instance, V1Alert entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Alerts?.Attach, cancellationToken); /// - protected override Task CanCreateFromAsync(V1alpha1Instance instance, V1alpha1Alert entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Alerts?.Create, cancellationToken); + protected override Task CanCreateFromAsync(V1Instance instance, V1Alert entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Alerts?.Create, cancellationToken); /// - protected override async Task FindAsync(V1alpha1Alert entity, SeqConnection api, V1alpha1AlertSpec spec, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task FindAsync(V1Alert entity, SeqConnection api, V1AlertSpec spec, string defaultNamespace, CancellationToken cancellationToken) { return null; } /// - protected override async Task GetAsync(V1alpha1Alert entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task GetAsync(V1Alert entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) { try { @@ -76,13 +76,13 @@ public V1alpha1AlertController(IKubernetesClient kube, IMemoryCache cache, Looku } /// - protected override async Task ValidateUpdateAsync(V1alpha1Instance instance, V1alpha1Alert entity, AlertConf? conf, CancellationToken cancellationToken) + protected override async Task ValidateUpdateAsync(V1Instance instance, V1Alert entity, AlertConf? conf, CancellationToken cancellationToken) { return null; } /// - protected override async Task CreateAsync(V1alpha1Instance instance, V1alpha1Alert entity, SeqConnection api, AlertConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task CreateAsync(V1Instance instance, V1Alert entity, SeqConnection api, AlertConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} creating Alert in Seq.", EntityTypeName); var self = await api.Alerts.AddAsync(ApplyToApi(new AlertEntity(), conf, null), cancellationToken); @@ -91,7 +91,7 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a } /// - protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1Alert entity, SeqConnection api, string id, AlertInfo? info, AlertConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task UpdateAsync(V1Instance instance, V1Alert entity, SeqConnection api, string id, AlertInfo? info, AlertConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} updating Alert in Seq with id: {Id}", EntityTypeName, id); await api.Alerts.UpdateAsync(ApplyToApi(await api.Alerts.FindAsync(id, cancellationToken), conf, info), cancellationToken); @@ -99,7 +99,7 @@ protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1Ale } /// - protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) + protected override async Task DeleteAsync(V1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} deleting Alert from Seq with ID: {Id} (reason: Kubernetes entity deleted)", EntityTypeName, id); await api.Alerts.RemoveAsync(await api.Alerts.FindAsync(id, cancellationToken), cancellationToken); diff --git a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertSpec.cs b/src/Alethic.Seq.Operator/Alerts/V1AlertSpec.cs similarity index 68% rename from src/Alethic.Seq.Operator/Alerts/V1alpha1AlertSpec.cs rename to src/Alethic.Seq.Operator/Alerts/V1AlertSpec.cs index c02b479..ebdcf50 100644 --- a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertSpec.cs +++ b/src/Alethic.Seq.Operator/Alerts/V1AlertSpec.cs @@ -5,12 +5,12 @@ namespace Alethic.Seq.Operator.Alerts { - public class V1alpha1AlertSpec : V1alpha1InstanceEntitySpec + public class V1AlertSpec : V1InstanceEntitySpec { [JsonPropertyName("instanceRef")] [Required] - public V1alpha1InstanceReference? InstanceRef { get; set; } + public V1InstanceReference? InstanceRef { get; set; } [JsonPropertyName("conf")] [Required] diff --git a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertStatus.cs b/src/Alethic.Seq.Operator/Alerts/V1AlertStatus.cs similarity index 65% rename from src/Alethic.Seq.Operator/Alerts/V1alpha1AlertStatus.cs rename to src/Alethic.Seq.Operator/Alerts/V1AlertStatus.cs index 3b312d5..c2289e7 100644 --- a/src/Alethic.Seq.Operator/Alerts/V1alpha1AlertStatus.cs +++ b/src/Alethic.Seq.Operator/Alerts/V1AlertStatus.cs @@ -4,7 +4,7 @@ namespace Alethic.Seq.Operator.Alerts { - public class V1alpha1AlertStatus : V1alpha1InstanceEntityStatus + public class V1AlertStatus : V1InstanceEntityStatus { [JsonPropertyName("id")] @@ -14,7 +14,7 @@ public class V1alpha1AlertStatus : V1alpha1InstanceEntityStatus public AlertInfo? Info { get; set; } [JsonPropertyName("conditions")] - public IList Conditions { get; set; } = new List(); + public IList Conditions { get; set; } = new List(); } diff --git a/src/Alethic.Seq.Operator/Alerts/V1alpha1Alert.cs b/src/Alethic.Seq.Operator/Alerts/V1alpha1Alert.cs index b3aca3c..e6125a0 100644 --- a/src/Alethic.Seq.Operator/Alerts/V1alpha1Alert.cs +++ b/src/Alethic.Seq.Operator/Alerts/V1alpha1Alert.cs @@ -6,12 +6,20 @@ namespace Alethic.Seq.Operator.Alerts { + /// + /// The deprecated v1alpha1 version of . It shares the spec and status types, so the two + /// schemas are identical and the API server converts between them by relabelling apiVersion alone; no + /// conversion webhook is needed. Exists only so the CustomResourceDefinition keeps serving v1alpha1 to + /// existing clients: the operator works with , which is also the storage version. + /// + /// + /// Never give this type properties or attributes that change its schema. A schema that differs from v1 would + /// need a conversion webhook, and objects written through one version would lose fields when read through the other. + /// [EntityScope(EntityScope.Namespaced)] [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1alpha1", Kind = "Alert")] [KubernetesEntityShortNames("seqalert")] - public partial class V1alpha1Alert : - CustomKubernetesEntity, - V1alpha1InstanceEntity + public class V1alpha1Alert : CustomKubernetesEntity { } diff --git a/src/Alethic.Seq.Operator/ApiKey/V1ApiKey.cs b/src/Alethic.Seq.Operator/ApiKey/V1ApiKey.cs new file mode 100644 index 0000000..d2e2514 --- /dev/null +++ b/src/Alethic.Seq.Operator/ApiKey/V1ApiKey.cs @@ -0,0 +1,20 @@ +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace Alethic.Seq.Operator.ApiKey +{ + + [EntityScope(EntityScope.Namespaced)] + [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1", Kind = "ApiKey")] + [StorageVersion] + [KubernetesEntityShortNames("seqapikey")] + public partial class V1ApiKey : + CustomKubernetesEntity, + V1InstanceEntity + { + + } + +} diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs similarity index 90% rename from src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs rename to src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs index 066186c..add6aac 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs @@ -31,13 +31,13 @@ namespace Alethic.Seq.Operator.ApiKey { - [EntityRbac(typeof(V1alpha1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] - [EntityRbac(typeof(V1alpha1ApiKey), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] + [EntityRbac(typeof(V1ApiKey), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.All)] [EntityRbac(typeof(Eventsv1Event), Verbs = RbacVerb.All)] - public partial class V1alpha1ApiKeyController : - V1alpha1InstanceEntityController, - IEntityController + public partial class V1ApiKeyController : + V1InstanceEntityController, + IEntityController { static string? EmptyToNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value; @@ -53,7 +53,7 @@ public partial class V1alpha1ApiKeyController : /// /// /// - public V1alpha1ApiKeyController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1ApiKeyController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -66,7 +66,7 @@ public V1alpha1ApiKeyController(IKubernetesClient kube, IMemoryCache cache, Look /// Returns true if this ApiKey is the deployment token which has automatic permissions. This token has all permissions always. /// /// - bool IsDeploymentToken(V1alpha1Instance instance, V1alpha1ApiKey entity) + bool IsDeploymentToken(V1Instance instance, V1ApiKey entity) { // remote instance can never have an admin token if (instance.Spec.Remote is not null) @@ -115,7 +115,7 @@ bool IsDeploymentToken(V1alpha1Instance instance, V1alpha1ApiKey entity) /// /// /// - internal static List GetDeniedPermissions(V1alpha1Instance instance, V1Namespace ns, IEnumerable permissions) + internal static List GetDeniedPermissions(V1Instance instance, V1Namespace ns, IEnumerable permissions) { return permissions .Distinct() @@ -124,13 +124,13 @@ internal static List GetDeniedPermissions(V1alpha1Instance ins } /// - protected override async Task CanAttachFromAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, CancellationToken cancellationToken) + protected override async Task CanAttachFromAsync(V1Instance instance, V1ApiKey entity, CancellationToken cancellationToken) { return IsDeploymentToken(instance, entity) || await instance.CheckPermissionAsync(Lookup, entity, false, p => p.ApiKeys?.Attach, cancellationToken); } /// - protected override async Task CanCreateFromAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, CancellationToken cancellationToken) + protected override async Task CanCreateFromAsync(V1Instance instance, V1ApiKey entity, CancellationToken cancellationToken) { return IsDeploymentToken(instance, entity) || await instance.CheckPermissionAsync(Lookup, entity, false, p => p.ApiKeys?.Create, cancellationToken); } @@ -141,7 +141,7 @@ protected override async Task CanCreateFromAsync(V1alpha1Instance instance /// /// /// - async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, CancellationToken cancellationToken) + async Task CanSetTitleAsync(V1Instance instance, V1ApiKey entity, CancellationToken cancellationToken) { return IsDeploymentToken(instance, entity) || await instance.CheckPermissionAsync(Lookup, entity, false, p => p.ApiKeys?.SetTitle, cancellationToken); } @@ -155,7 +155,7 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti /// /// /// - async Task FindByTitleAsync(V1alpha1ApiKey entity, SeqConnection api, string title, string defaultNamespace, CancellationToken cancellationToken) + async Task FindByTitleAsync(V1ApiKey entity, SeqConnection api, string title, string defaultNamespace, CancellationToken cancellationToken) { try { @@ -178,7 +178,7 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti } /// - protected override async Task FindAsync(V1alpha1ApiKey entity, SeqConnection api, V1alpha1ApiKeySpec spec, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task FindAsync(V1ApiKey entity, SeqConnection api, V1ApiKeySpec spec, string defaultNamespace, CancellationToken cancellationToken) { // no manually specified title // this would result in an auto generated title, which we can search on safely @@ -189,7 +189,7 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti } /// - protected override async Task GetAsync(V1alpha1ApiKey entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task GetAsync(V1ApiKey entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) { try { @@ -202,7 +202,7 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti } /// - protected override async Task ValidateUpdateAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, ApiKeyConf? conf, CancellationToken cancellationToken) + protected override async Task ValidateUpdateAsync(V1Instance instance, V1ApiKey entity, ApiKeyConf? conf, CancellationToken cancellationToken) { var ns = await Lookup.ResolveNamespaceAsync(entity.Namespace(), cancellationToken); if (ns is null) @@ -221,7 +221,7 @@ async Task CanSetTitleAsync(V1alpha1Instance instance, V1alpha1ApiKey enti } /// - protected override async Task CreateAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, SeqConnection api, ApiKeyConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task CreateAsync(V1Instance instance, V1ApiKey entity, SeqConnection api, ApiKeyConf? conf, string defaultNamespace, CancellationToken cancellationToken) { // create a new entity var create = new ApiKeyEntity(); @@ -256,14 +256,14 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a } /// - protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1ApiKey entity, SeqConnection api, string id, ApiKeyInfo? info, ApiKeyConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task UpdateAsync(V1Instance instance, V1ApiKey entity, SeqConnection api, string id, ApiKeyInfo? info, ApiKeyConf? conf, string defaultNamespace, CancellationToken cancellationToken) { await ApplySecretAsync(entity, api.Client.ServerUrl, null, defaultNamespace, cancellationToken); await api.ApiKeys.UpdateAsync(ApplyToApi(entity, await api.ApiKeys.FindAsync(id, cancellationToken), conf), cancellationToken); } /// - protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) + protected override async Task DeleteAsync(V1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) { await api.ApiKeys.RemoveAsync(await api.ApiKeys.FindAsync(id, cancellationToken), cancellationToken); } @@ -275,7 +275,7 @@ protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnecti /// /// /// - async Task FindRelatedAsync(V1alpha1ApiKey apikey, CancellationToken cancellationToken) + async Task FindRelatedAsync(V1ApiKey apikey, CancellationToken cancellationToken) where T : k8s.IKubernetesObject { var l = await Kube.ListAsync(apikey.Namespace(), $"seq.k8s.datalust.co/apikey={apikey.Name()}", cancellationToken: cancellationToken); @@ -290,7 +290,7 @@ protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnecti /// /// /// - async Task ApplySecretAsync(V1alpha1ApiKey entity, string? endpoint, string? token, string defaultNamespace, CancellationToken cancellationToken) + async Task ApplySecretAsync(V1ApiKey entity, string? endpoint, string? token, string defaultNamespace, CancellationToken cancellationToken) { var secretName = entity.Spec.SecretRef?.Name ?? entity.Name() + "-seq-apikey"; var secretNamespace = entity.Spec.SecretRef?.NamespaceProperty ?? entity.Namespace(); @@ -452,7 +452,7 @@ ApiKeyInputSettings ToInfo(InputSettingsPart source) /// /// /// - ApiKeyEntity ApplyToApi(V1alpha1ApiKey entity, ApiKeyEntity target, ApiKeyConf? conf) + ApiKeyEntity ApplyToApi(V1ApiKey entity, ApiKeyEntity target, ApiKeyConf? conf) { var title = conf?.Title ?? "SeqOperatorApiKey_" + entity.Uid(); if (title is not null) diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeySpec.cs b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeySpec.cs similarity index 79% rename from src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeySpec.cs rename to src/Alethic.Seq.Operator/ApiKey/V1ApiKeySpec.cs index d2b131d..8c9f227 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeySpec.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeySpec.cs @@ -7,12 +7,12 @@ namespace Alethic.Seq.Operator.ApiKey { - public class V1alpha1ApiKeySpec : V1alpha1InstanceEntitySpec + public class V1ApiKeySpec : V1InstanceEntitySpec { [JsonPropertyName("instanceRef")] [Required] - public V1alpha1InstanceReference? InstanceRef { get; set; } + public V1InstanceReference? InstanceRef { get; set; } /// /// Reference to secret where the ApiKey will be read from or stored to. diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyStatus.cs b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyStatus.cs similarity index 71% rename from src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyStatus.cs rename to src/Alethic.Seq.Operator/ApiKey/V1ApiKeyStatus.cs index 4c00419..99fef26 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyStatus.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyStatus.cs @@ -6,7 +6,7 @@ namespace Alethic.Seq.Operator.ApiKey { - public class V1alpha1ApiKeyStatus : V1alpha1InstanceEntityStatus + public class V1ApiKeyStatus : V1InstanceEntityStatus { [JsonPropertyName("id")] @@ -19,7 +19,7 @@ public class V1alpha1ApiKeyStatus : V1alpha1InstanceEntityStatus public ApiKeyInfo? Info { get; set; } [JsonPropertyName("conditions")] - public IList Conditions { get; set; } = new List(); + public IList Conditions { get; set; } = new List(); } diff --git a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKey.cs b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKey.cs index 9b47ca0..6314527 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKey.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKey.cs @@ -6,14 +6,22 @@ namespace Alethic.Seq.Operator.ApiKey { + /// + /// The deprecated v1alpha1 version of . It shares the spec and status types, so the two + /// schemas are identical and the API server converts between them by relabelling apiVersion alone; no + /// conversion webhook is needed. Exists only so the CustomResourceDefinition keeps serving v1alpha1 to + /// existing clients: the operator works with , which is also the storage version. + /// + /// + /// Never give this type properties or attributes that change its schema. A schema that differs from v1 would + /// need a conversion webhook, and objects written through one version would lose fields when read through the other. + /// [EntityScope(EntityScope.Namespaced)] [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1alpha1", Kind = "ApiKey")] [KubernetesEntityShortNames("seqapikey")] - public partial class V1alpha1ApiKey : - CustomKubernetesEntity, - V1alpha1InstanceEntity + public class V1alpha1ApiKey : CustomKubernetesEntity { - + } } diff --git a/src/Alethic.Seq.Operator/Finalizers/AlertFinalizer.cs b/src/Alethic.Seq.Operator/Finalizers/AlertFinalizer.cs index cf5ad4c..e93f9a7 100644 --- a/src/Alethic.Seq.Operator/Finalizers/AlertFinalizer.cs +++ b/src/Alethic.Seq.Operator/Finalizers/AlertFinalizer.cs @@ -6,18 +6,18 @@ namespace Alethic.Seq.Operator.Finalizers { /// - /// Finalizes , writing seq.k8s.datalust.co/alertfinalizer into + /// Finalizes , writing seq.k8s.datalust.co/alertfinalizer into /// metadata.finalizers. The name carries no API version deliberately; see /// . /// - public class AlertFinalizer : EntityFinalizerBase + public class AlertFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public AlertFinalizer(IEntityController controller) : + public AlertFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Finalizers/ApiKeyFinalizer.cs b/src/Alethic.Seq.Operator/Finalizers/ApiKeyFinalizer.cs index f68a718..71e89cf 100644 --- a/src/Alethic.Seq.Operator/Finalizers/ApiKeyFinalizer.cs +++ b/src/Alethic.Seq.Operator/Finalizers/ApiKeyFinalizer.cs @@ -6,18 +6,18 @@ namespace Alethic.Seq.Operator.Finalizers { /// - /// Finalizes , writing seq.k8s.datalust.co/apikeyfinalizer into + /// Finalizes , writing seq.k8s.datalust.co/apikeyfinalizer into /// metadata.finalizers. The name carries no API version deliberately; see /// . /// - public class ApiKeyFinalizer : EntityFinalizerBase + public class ApiKeyFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public ApiKeyFinalizer(IEntityController controller) : + public ApiKeyFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Finalizers/EntityFinalizers.cs b/src/Alethic.Seq.Operator/Finalizers/EntityFinalizers.cs index b0cccd0..16d7eaf 100644 --- a/src/Alethic.Seq.Operator/Finalizers/EntityFinalizers.cs +++ b/src/Alethic.Seq.Operator/Finalizers/EntityFinalizers.cs @@ -32,7 +32,7 @@ namespace Alethic.Seq.Operator.Finalizers /// without removing it when no finalizer is registered under that identifier, so an entity whose first finalizer /// belongs to a retired release can never finish deleting. The retired identifiers therefore stay registered -- /// via the shim classes in -- so entities already carrying them drain, and - /// strips them from live entities so they + /// strips them from live entities so they /// are not carried forward. Current finalizer names are version-free and must not change again. /// /// @@ -62,15 +62,15 @@ record struct Registration(string Current, ImmutableHashSet Retired); /// static readonly Dictionary _registrations = new() { - [typeof(V1alpha1Alert)] = Register( + [typeof(V1Alert)] = Register( Identifier()), - [typeof(V1alpha1ApiKey)] = Register( + [typeof(V1ApiKey)] = Register( Identifier()), - [typeof(V1alpha1Instance)] = Register( + [typeof(V1Instance)] = Register( Identifier()), - [typeof(V1alpha1RetentionPolicy)] = Register( + [typeof(V1RetentionPolicy)] = Register( Identifier()), - [typeof(V1alpha1Signal)] = Register( + [typeof(V1Signal)] = Register( Identifier()), }; diff --git a/src/Alethic.Seq.Operator/Finalizers/InstanceFinalizer.cs b/src/Alethic.Seq.Operator/Finalizers/InstanceFinalizer.cs index d81b294..613dac0 100644 --- a/src/Alethic.Seq.Operator/Finalizers/InstanceFinalizer.cs +++ b/src/Alethic.Seq.Operator/Finalizers/InstanceFinalizer.cs @@ -6,18 +6,18 @@ namespace Alethic.Seq.Operator.Finalizers { /// - /// Finalizes , writing seq.k8s.datalust.co/instancefinalizer into + /// Finalizes , writing seq.k8s.datalust.co/instancefinalizer into /// metadata.finalizers. The name carries no API version deliberately; see /// . /// - public class InstanceFinalizer : EntityFinalizerBase + public class InstanceFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public InstanceFinalizer(IEntityController controller) : + public InstanceFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Finalizers/Legacy/LegacyFinalizers.cs b/src/Alethic.Seq.Operator/Finalizers/Legacy/LegacyFinalizers.cs index 9baeade..63110b9 100644 --- a/src/Alethic.Seq.Operator/Finalizers/Legacy/LegacyFinalizers.cs +++ b/src/Alethic.Seq.Operator/Finalizers/Legacy/LegacyFinalizers.cs @@ -17,16 +17,16 @@ namespace Alethic.Seq.Operator.Finalizers.Legacy /// /// Drains seq.k8s.datalust.co/v1alpha1alertfinalizer, the identifier written for - /// while the finalizer class was named V1alpha1AlertFinalizer. + /// while the finalizer class was named V1alpha1AlertFinalizer. /// - public class V1alpha1AlertFinalizer : EntityFinalizerBase + public class V1alpha1AlertFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public V1alpha1AlertFinalizer(IEntityController controller) : + public V1alpha1AlertFinalizer(IEntityController controller) : base(controller) { @@ -36,16 +36,16 @@ public V1alpha1AlertFinalizer(IEntityController controller) : /// /// Drains seq.k8s.datalust.co/v1alpha1apikeyfinalizer, the identifier written for - /// while the finalizer class was named V1alpha1ApiKeyFinalizer. + /// while the finalizer class was named V1alpha1ApiKeyFinalizer. /// - public class V1alpha1ApiKeyFinalizer : EntityFinalizerBase + public class V1alpha1ApiKeyFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public V1alpha1ApiKeyFinalizer(IEntityController controller) : + public V1alpha1ApiKeyFinalizer(IEntityController controller) : base(controller) { @@ -55,16 +55,16 @@ public V1alpha1ApiKeyFinalizer(IEntityController controller) : /// /// Drains seq.k8s.datalust.co/v1alpha1instancefinalizer, the identifier written for - /// while the finalizer class was named V1alpha1InstanceFinalizer. + /// while the finalizer class was named V1alpha1InstanceFinalizer. /// - public class V1alpha1InstanceFinalizer : EntityFinalizerBase + public class V1alpha1InstanceFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public V1alpha1InstanceFinalizer(IEntityController controller) : + public V1alpha1InstanceFinalizer(IEntityController controller) : base(controller) { @@ -74,16 +74,16 @@ public V1alpha1InstanceFinalizer(IEntityController controller) /// /// Drains seq.k8s.datalust.co/v1alpha1retentionpolicyfinalizer, the identifier written for - /// while the finalizer class was named V1alpha1RetentionPolicyFinalizer. + /// while the finalizer class was named V1alpha1RetentionPolicyFinalizer. /// - public class V1alpha1RetentionPolicyFinalizer : EntityFinalizerBase + public class V1alpha1RetentionPolicyFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public V1alpha1RetentionPolicyFinalizer(IEntityController controller) : + public V1alpha1RetentionPolicyFinalizer(IEntityController controller) : base(controller) { @@ -93,16 +93,16 @@ public V1alpha1RetentionPolicyFinalizer(IEntityController /// Drains seq.k8s.datalust.co/v1alpha1signalfinalizer, the identifier written for - /// while the finalizer class was named V1alpha1SignalFinalizer. + /// while the finalizer class was named V1alpha1SignalFinalizer. /// - public class V1alpha1SignalFinalizer : EntityFinalizerBase + public class V1alpha1SignalFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public V1alpha1SignalFinalizer(IEntityController controller) : + public V1alpha1SignalFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Finalizers/RetentionPolicyFinalizer.cs b/src/Alethic.Seq.Operator/Finalizers/RetentionPolicyFinalizer.cs index c79319e..03409e9 100644 --- a/src/Alethic.Seq.Operator/Finalizers/RetentionPolicyFinalizer.cs +++ b/src/Alethic.Seq.Operator/Finalizers/RetentionPolicyFinalizer.cs @@ -6,18 +6,18 @@ namespace Alethic.Seq.Operator.Finalizers { /// - /// Finalizes , writing seq.k8s.datalust.co/retentionpolicyfinalizer into + /// Finalizes , writing seq.k8s.datalust.co/retentionpolicyfinalizer into /// metadata.finalizers. The name carries no API version deliberately; see /// . /// - public class RetentionPolicyFinalizer : EntityFinalizerBase + public class RetentionPolicyFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public RetentionPolicyFinalizer(IEntityController controller) : + public RetentionPolicyFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Finalizers/SignalFinalizer.cs b/src/Alethic.Seq.Operator/Finalizers/SignalFinalizer.cs index 25ae413..5eb014d 100644 --- a/src/Alethic.Seq.Operator/Finalizers/SignalFinalizer.cs +++ b/src/Alethic.Seq.Operator/Finalizers/SignalFinalizer.cs @@ -6,18 +6,18 @@ namespace Alethic.Seq.Operator.Finalizers { /// - /// Finalizes , writing seq.k8s.datalust.co/signalfinalizer into + /// Finalizes , writing seq.k8s.datalust.co/signalfinalizer into /// metadata.finalizers. The name carries no API version deliberately; see /// . /// - public class SignalFinalizer : EntityFinalizerBase + public class SignalFinalizer : EntityFinalizerBase { /// /// Initializes a new instance. /// /// - public SignalFinalizer(IEntityController controller) : + public SignalFinalizer(IEntityController controller) : base(controller) { diff --git a/src/Alethic.Seq.Operator/Instance/V1Instance.cs b/src/Alethic.Seq.Operator/Instance/V1Instance.cs new file mode 100644 index 0000000..28d0545 --- /dev/null +++ b/src/Alethic.Seq.Operator/Instance/V1Instance.cs @@ -0,0 +1,154 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +using k8s; +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +using Microsoft.Extensions.Logging; + +namespace Alethic.Seq.Operator.Instance +{ + + [EntityScope(EntityScope.Namespaced)] + [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1", Kind = "Instance")] + [StorageVersion] + [KubernetesEntityShortNames("seqinstance")] + public partial class V1Instance : + CustomKubernetesEntity, + V1Entity + { + + /// + /// Default permission set if none specified. + /// + readonly static InstancePermission DefaultPermissions = new InstancePermission() + { + Namespaces = new InstancePermissionNamespaces + { + From = InstancePermissionNamespaceFrom.Same, + }, + Alerts = new InstanceAlertPermissions() + { + Attach = true, + Create = true, + }, + ApiKeys = new InstanceApiKeyPermissions() + { + Attach = true, + Create = true, + SetTitle = true, + SetIngest = true, + SetOrganization = true, + SetProject = true, + SetPublic = true, + SetRead = true, + SetSystem = true, + SetWrite = true, + }, + RetentionPolicies = new InstanceRetentionPolicyPermissions() + { + Attach = true, + Create = true, + }, + Signals = new InstanceSignalPermissions() + { + Attach = true, + Create = true, + }, + }; + + /// + /// Evaluates whether the given ns matches the specified label selector. + /// + /// + /// + /// + bool EvalLabelSelector(V1Namespace ns, V1LabelSelector selector) + { + // evaluate labels + if (selector.MatchLabels is { } matchLabels) + foreach (var kvp in matchLabels) + if (ns.GetLabel(kvp.Key) is not string s || kvp.Value != s) + return false; + + // evaluate expressions + if (selector.MatchExpressions is { } matchExpressions) + foreach (var matchExpression in matchExpressions) + throw new NotSupportedException("MatchExpressions are not yet supported."); + + return true; + } + + /// + /// Evaluates the given against the specified namespace name. + /// + /// + /// + /// + bool MatchPermission(V1Namespace ns, InstancePermission permission) + { + switch (permission.Namespaces?.From ?? InstancePermissionNamespaceFrom.Same) + { + case InstancePermissionNamespaceFrom.All: + return true; + case InstancePermissionNamespaceFrom.Same: + return ns.Name() == Metadata.Namespace(); + case InstancePermissionNamespaceFrom.Selector: + return permission.Namespaces?.Selector is { } selector && EvalLabelSelector(ns, selector); + default: + return false; + } + } + + /// + /// Gets the first that apply to the specified namespace. + /// + /// + /// + InstancePermission? MatchPermissions(V1Namespace ns) + { + foreach (var p in Spec.Permissions ?? [DefaultPermissions]) + if (MatchPermission(ns, p)) + return p; + + return null; + } + + /// + /// Checks the given permission boolean. + /// + /// + /// + /// + /// + public bool CheckPermission(V1Namespace ns, bool defaultValue, Func func) + { + return MatchPermissions(ns) is { } p ? func(p) ?? defaultValue : defaultValue; + } + + /// + /// Checks the given permission boolean. + /// + /// + /// + /// + /// + /// + /// + /// + public async Task CheckPermissionAsync(LookupService lookup, IKubernetesObject entity, bool defaultValue, Func func, CancellationToken cancellationToken) + { + var ns = await lookup.ResolveNamespaceAsync(entity.Namespace(), cancellationToken); + if (ns is null) + throw new InvalidOperationException($"{entity.Namespace()}/{entity.Name()} is invalid: cannot retrieve namespace."); + + return CheckPermission(ns, defaultValue, func); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs b/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs similarity index 94% rename from src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs rename to src/Alethic.Seq.Operator/Instance/V1InstanceController.cs index b4abc3c..3eace30 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs @@ -33,15 +33,15 @@ namespace Alethic.Seq.Operator.Instance // the controller's entity types, so without it the generated role omits namespaces and has to be patched by hand // wherever it is copied. [EntityRbac(typeof(V1Namespace), Verbs = RbacVerb.Get | RbacVerb.List | RbacVerb.Watch)] - [EntityRbac(typeof(V1alpha1Instance), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Instance), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Service), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1ServiceAccount), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1StatefulSet), Verbs = RbacVerb.All)] - [EntityRbac(typeof(V1alpha1ApiKey), Verbs = RbacVerb.All)] - public partial class V1alpha1InstanceController : - V1alpha1Controller, - IEntityController + [EntityRbac(typeof(V1ApiKey), Verbs = RbacVerb.All)] + public partial class V1InstanceController : + V1Controller, + IEntityController { const string PasswordLowerChars = "abcdefghijkmnopqrstuvwxyz"; @@ -81,7 +81,7 @@ internal static string GeneratePassword(int length) /// /// /// - public V1alpha1InstanceController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1InstanceController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -93,12 +93,12 @@ public V1alpha1InstanceController(IKubernetesClient kube, IMemoryCache cache, Lo /// /// /// - public string CalculatePasswordHash(V1alpha1Instance instance, string password) + public string CalculatePasswordHash(V1Instance instance, string password) { var instanceNamespace = instance.Namespace(); var instanceName = instance.Name(); - return Cache.GetOrCreate((typeof(V1alpha1InstanceController), nameof(CalculatePasswordHash), instanceNamespace, instanceName, password), e => + return Cache.GetOrCreate((typeof(V1InstanceController), nameof(CalculatePasswordHash), instanceNamespace, instanceName, password), e => { var salt = SHA256.HashData(Encoding.UTF8.GetBytes($"{instanceNamespace}/{instanceName}"))[0..16]; var hash = SeqPasswordHash.Calculate(password, salt); @@ -112,7 +112,7 @@ public string CalculatePasswordHash(V1alpha1Instance instance, string password) protected override string EntityTypeName => "Instance"; /// - protected override async Task Reconcile(V1alpha1Instance instance, CancellationToken cancellationToken) + protected override async Task Reconcile(V1Instance instance, CancellationToken cancellationToken) { // deploy locally if no remote specified if (instance.Spec.Remote is null) @@ -149,7 +149,7 @@ protected override async Task Reconcile(V1alpha1Instance insta /// /// /// - async Task ReconcileDeploymentAsync(V1alpha1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) + async Task ReconcileDeploymentAsync(V1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) { deployment ??= new InstanceDeploymentSpec(); @@ -186,7 +186,7 @@ async Task ReconcileDeploymentAsync(V1alpha1Instance instance, /// /// /// - async Task GetRelatedObject(V1alpha1Instance instance, string resource, CancellationToken cancellationToken) + async Task GetRelatedObject(V1Instance instance, string resource, CancellationToken cancellationToken) where T : IKubernetesObject { var l = await Kube.ListAsync(instance.Namespace(), $"seq.k8s.datalust.co/instance={instance.Name()},seq.k8s.datalust.co/resource={resource}", cancellationToken: cancellationToken); @@ -201,7 +201,7 @@ async Task ReconcileDeploymentAsync(V1alpha1Instance instance, /// /// /// - async Task ReconcileDeploymentLoginSecretAsync(V1alpha1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) + async Task ReconcileDeploymentLoginSecretAsync(V1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) { var operatorLoginSecret = await GetRelatedObject(instance, "operator-login", cancellationToken); @@ -266,7 +266,7 @@ async Task ReconcileDeploymentAsync(V1alpha1Instance instance, /// /// /// - V1Secret ApplyDeploymentLoginSecret(V1alpha1Instance instance, InstanceDeploymentSpec deployment, V1Secret adminSecret) + V1Secret ApplyDeploymentLoginSecret(V1Instance instance, InstanceDeploymentSpec deployment, V1Secret adminSecret) { adminSecret.Data ??= new Dictionary(); adminSecret.StringData ??= new Dictionary(); @@ -293,9 +293,9 @@ V1Secret ApplyDeploymentLoginSecret(V1alpha1Instance instance, InstanceDeploymen /// /// /// - async Task ReconcileDeploymentAdminApiKeyAsync(V1alpha1Instance instance, InstanceDeploymentSpec deployment, CancellationToken cancellationToken) + async Task ReconcileDeploymentAdminApiKeyAsync(V1Instance instance, InstanceDeploymentSpec deployment, CancellationToken cancellationToken) { - var operatorTokenApiKey = await GetRelatedObject(instance, "operator-token", cancellationToken); + var operatorTokenApiKey = await GetRelatedObject(instance, "operator-token", cancellationToken); // no deployment, we have an existing operator apikey owned by us, delete if (deployment is null && operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedBy(instance)) @@ -335,7 +335,7 @@ V1Secret ApplyDeploymentLoginSecret(V1alpha1Instance instance, InstanceDeploymen ApplyDeploymentAdminApiKey( instance, deployment, - new V1alpha1ApiKey() { Metadata = new V1ObjectMeta() { NamespaceProperty = operatorTokenApiKeyNamespace, Name = operatorTokenApiKeyName } }.WithOwnerReference(instance))), + new V1ApiKey() { Metadata = new V1ObjectMeta() { NamespaceProperty = operatorTokenApiKeyNamespace, Name = operatorTokenApiKeyName } }.WithOwnerReference(instance))), cancellationToken); } @@ -359,10 +359,10 @@ V1Secret ApplyDeploymentLoginSecret(V1alpha1Instance instance, InstanceDeploymen /// /// /// - V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDeploymentSpec deployment, V1alpha1ApiKey apikey) + V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec deployment, V1ApiKey apikey) { - apikey.Spec ??= new V1alpha1ApiKeySpec(); - apikey.Spec.InstanceRef = new V1alpha1InstanceReference() { Name = instance.Name(), Namespace = instance.Namespace() }; + apikey.Spec ??= new V1ApiKeySpec(); + apikey.Spec.InstanceRef = new V1InstanceReference() { Name = instance.Name(), Namespace = instance.Namespace() }; apikey.Spec.SecretRef = deployment.TokenSecretRef; apikey.Spec.Conf = new ApiKeyConf(); apikey.Spec.Conf.Permissions = [ApiKeyPermission.Public, ApiKeyPermission.Ingest, ApiKeyPermission.Read, ApiKeyPermission.Write, ApiKeyPermission.Project, ApiKeyPermission.System, ApiKeyPermission.Organization]; @@ -376,7 +376,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep /// /// /// - async Task ReconcileDeploymentServiceAccountAsync(V1alpha1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) + async Task ReconcileDeploymentServiceAccountAsync(V1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) { var serviceAccount = await GetRelatedObject(instance, "service-account", cancellationToken); @@ -438,7 +438,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep /// /// /// - async Task ReconcileDeploymentStatefulSetAsync(V1alpha1Instance instance, InstanceDeploymentSpec? deployment, V1Secret? adminSecret, V1ServiceAccount? serviceAccount, V1Service? service, CancellationToken cancellationToken) + async Task ReconcileDeploymentStatefulSetAsync(V1Instance instance, InstanceDeploymentSpec? deployment, V1Secret? adminSecret, V1ServiceAccount? serviceAccount, V1Service? service, CancellationToken cancellationToken) { var statefulSet = await GetRelatedObject(instance, "stateful-set", cancellationToken); @@ -515,7 +515,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep /// /// /// - V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDeploymentSpec deployment, V1StatefulSet statefulSet, V1Secret adminSecret, V1ServiceAccount serviceAccount, V1Service service) + V1StatefulSet ApplyDeploymentStatefulSet(V1Instance instance, InstanceDeploymentSpec deployment, V1StatefulSet statefulSet, V1Secret adminSecret, V1ServiceAccount serviceAccount, V1Service service) { statefulSet.Spec ??= new(); // spec.serviceName cannot be changed once the StatefulSet exists, so it is only set on creation. Sets created @@ -655,7 +655,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDepl /// /// /// - async Task ReconcileDeploymentServiceAsync(V1alpha1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) + async Task ReconcileDeploymentServiceAsync(V1Instance instance, InstanceDeploymentSpec? deployment, CancellationToken cancellationToken) { var service = await GetRelatedObject(instance, "service", cancellationToken); @@ -719,7 +719,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDepl /// /// /// - V1Service ApplyDeploymentService(V1alpha1Instance instance, InstanceDeploymentSpec deployment, V1Service service) + V1Service ApplyDeploymentService(V1Instance instance, InstanceDeploymentSpec deployment, V1Service service) { var m = service.EnsureMetadata(); var l = m.EnsureLabels(); @@ -778,7 +778,7 @@ V1Service ApplyDeploymentService(V1alpha1Instance instance, InstanceDeploymentSp /// /// /// - T ApplyDeploymentResource(V1alpha1Instance instance, InstanceDeploymentSpec deployment, string resource, T entity) + T ApplyDeploymentResource(V1Instance instance, InstanceDeploymentSpec deployment, string resource, T entity) where T : IMetadata { var m = entity.EnsureMetadata(); @@ -803,10 +803,10 @@ T ApplyDeploymentResource(V1alpha1Instance instance, InstanceDeploymentSpec d } /// - public override Task> DeletedAsync(V1alpha1Instance entity, CancellationToken cancellationToken) + public override Task> DeletedAsync(V1Instance entity, CancellationToken cancellationToken) { Logger.LogWarning("Unsupported operation deleting entity {Entity}.", entity); - return Task.FromResult(ReconciliationResult.Success(entity)); + return Task.FromResult(ReconciliationResult.Success(entity)); } /// @@ -949,7 +949,7 @@ async Task GetAuthSettingsAsync(SeqConnection api, InstanceConfAuthenticationSpe /// /// /// - async Task ResolveSecretSettingAsync(V1alpha1Instance instance, string name, string? value, V1SecretKeySelector? valueRef, CancellationToken cancellationToken) + async Task ResolveSecretSettingAsync(V1Instance instance, string name, string? value, V1SecretKeySelector? valueRef, CancellationToken cancellationToken) { if (value is not null && valueRef is not null) throw new InvalidOperationException($"{EntityTypeName} {instance.Namespace()}/{instance.Name()} is invalid: set only one of {name} and {name}Ref."); @@ -982,7 +982,7 @@ internal static InstanceInfo RedactSecrets(InstanceInfo info) return info; } - async Task PutSettingValueAsync(V1alpha1Instance instance, SeqConnection api, SettingName name, T? value, CancellationToken cancellationToken) + async Task PutSettingValueAsync(V1Instance instance, SeqConnection api, SettingName name, T? value, CancellationToken cancellationToken) { Logger.LogDebug("{EntityTypeName} {EntityNamespace}/{EntityName} putting Seq setting {SettingName}.", EntityTypeName, instance.Namespace(), instance.Name(), name); var setting = await api.Settings.FindNamedAsync(name, cancellationToken); @@ -999,7 +999,7 @@ async Task PutSettingValueAsync(V1alpha1Instance instance, SeqConnection api, /// /// /// - async Task PutConfAsync(V1alpha1Instance instance, SeqConnection api, InstanceInfo? info, InstanceConf conf, CancellationToken cancellationToken) + async Task PutConfAsync(V1Instance instance, SeqConnection api, InstanceInfo? info, InstanceConf conf, CancellationToken cancellationToken) { if (conf is not null) { @@ -1121,7 +1121,7 @@ async Task PutConfAsync(V1alpha1Instance instance, SeqConnection api, InstanceIn /// /// /// - async Task PutAuthSettingsAsync(V1alpha1Instance instance, SeqConnection api, InstanceConfAuthenticationSpec? info, InstanceConfAuthenticationSpec conf, CancellationToken cancellationToken) + async Task PutAuthSettingsAsync(V1Instance instance, SeqConnection api, InstanceConfAuthenticationSpec? info, InstanceConfAuthenticationSpec conf, CancellationToken cancellationToken) { if (conf.Local is { } local) { diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceSpec.cs b/src/Alethic.Seq.Operator/Instance/V1InstanceSpec.cs similarity index 92% rename from src/Alethic.Seq.Operator/Instance/V1alpha1InstanceSpec.cs rename to src/Alethic.Seq.Operator/Instance/V1InstanceSpec.cs index 5965e7c..c82d0a3 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceSpec.cs +++ b/src/Alethic.Seq.Operator/Instance/V1InstanceSpec.cs @@ -3,7 +3,7 @@ namespace Alethic.Seq.Operator.Instance { - public partial class V1alpha1InstanceSpec : V1alpha1EntitySpec + public partial class V1InstanceSpec : V1EntitySpec { /// diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceStatus.cs b/src/Alethic.Seq.Operator/Instance/V1InstanceStatus.cs similarity index 72% rename from src/Alethic.Seq.Operator/Instance/V1alpha1InstanceStatus.cs rename to src/Alethic.Seq.Operator/Instance/V1InstanceStatus.cs index 25def8e..5d82458 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceStatus.cs +++ b/src/Alethic.Seq.Operator/Instance/V1InstanceStatus.cs @@ -4,7 +4,7 @@ namespace Alethic.Seq.Operator.Instance { - public class V1alpha1InstanceStatus : V1alpha1EntityStatus + public class V1InstanceStatus : V1EntityStatus { /// @@ -17,7 +17,7 @@ public class V1alpha1InstanceStatus : V1alpha1EntityStatus public InstanceInfo? Info { get; set; } [JsonPropertyName("conditions")] - public IList Conditions { get; set; } = new List(); + public IList Conditions { get; set; } = new List(); } diff --git a/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs b/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs index 558fa98..00f07d7 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs @@ -1,153 +1,27 @@ -using System; -using System.Threading; -using System.Threading.Tasks; - -using k8s; -using k8s.Models; +using k8s.Models; using KubeOps.Abstractions.Entities; using KubeOps.Abstractions.Entities.Attributes; -using Microsoft.Extensions.Logging; - namespace Alethic.Seq.Operator.Instance { + /// + /// The deprecated v1alpha1 version of . It shares the spec and status types, so the two + /// schemas are identical and the API server converts between them by relabelling apiVersion alone; no + /// conversion webhook is needed. Exists only so the CustomResourceDefinition keeps serving v1alpha1 to + /// existing clients: the operator works with , which is also the storage version. + /// + /// + /// Never give this type properties or attributes that change its schema. A schema that differs from v1 would + /// need a conversion webhook, and objects written through one version would lose fields when read through the other. + /// [EntityScope(EntityScope.Namespaced)] [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1alpha1", Kind = "Instance")] [KubernetesEntityShortNames("seqinstance")] - public partial class V1alpha1Instance : - CustomKubernetesEntity, - V1alpha1Entity + public class V1alpha1Instance : CustomKubernetesEntity { - /// - /// Default permission set if none specified. - /// - readonly static InstancePermission DefaultPermissions = new InstancePermission() - { - Namespaces = new InstancePermissionNamespaces - { - From = InstancePermissionNamespaceFrom.Same, - }, - Alerts = new InstanceAlertPermissions() - { - Attach = true, - Create = true, - }, - ApiKeys = new InstanceApiKeyPermissions() - { - Attach = true, - Create = true, - SetTitle = true, - SetIngest = true, - SetOrganization = true, - SetProject = true, - SetPublic = true, - SetRead = true, - SetSystem = true, - SetWrite = true, - }, - RetentionPolicies = new InstanceRetentionPolicyPermissions() - { - Attach = true, - Create = true, - }, - Signals = new InstanceSignalPermissions() - { - Attach = true, - Create = true, - }, - }; - - /// - /// Evaluates whether the given ns matches the specified label selector. - /// - /// - /// - /// - bool EvalLabelSelector(V1Namespace ns, V1LabelSelector selector) - { - // evaluate labels - if (selector.MatchLabels is { } matchLabels) - foreach (var kvp in matchLabels) - if (ns.GetLabel(kvp.Key) is not string s || kvp.Value != s) - return false; - - // evaluate expressions - if (selector.MatchExpressions is { } matchExpressions) - foreach (var matchExpression in matchExpressions) - throw new NotSupportedException("MatchExpressions are not yet supported."); - - return true; - } - - /// - /// Evaluates the given against the specified namespace name. - /// - /// - /// - /// - bool MatchPermission(V1Namespace ns, InstancePermission permission) - { - switch (permission.Namespaces?.From ?? InstancePermissionNamespaceFrom.Same) - { - case InstancePermissionNamespaceFrom.All: - return true; - case InstancePermissionNamespaceFrom.Same: - return ns.Name() == Metadata.Namespace(); - case InstancePermissionNamespaceFrom.Selector: - return permission.Namespaces?.Selector is { } selector && EvalLabelSelector(ns, selector); - default: - return false; - } - } - - /// - /// Gets the first that apply to the specified namespace. - /// - /// - /// - InstancePermission? MatchPermissions(V1Namespace ns) - { - foreach (var p in Spec.Permissions ?? [DefaultPermissions]) - if (MatchPermission(ns, p)) - return p; - - return null; - } - - /// - /// Checks the given permission boolean. - /// - /// - /// - /// - /// - public bool CheckPermission(V1Namespace ns, bool defaultValue, Func func) - { - return MatchPermissions(ns) is { } p ? func(p) ?? defaultValue : defaultValue; - } - - /// - /// Checks the given permission boolean. - /// - /// - /// - /// - /// - /// - /// - /// - public async Task CheckPermissionAsync(LookupService lookup, IKubernetesObject entity, bool defaultValue, Func func, CancellationToken cancellationToken) - { - var ns = await lookup.ResolveNamespaceAsync(entity.Namespace(), cancellationToken); - if (ns is null) - throw new InvalidOperationException($"{entity.Namespace()}/{entity.Name()} is invalid: cannot retrieve namespace."); - - return CheckPermission(ns, defaultValue, func); - } - } } diff --git a/src/Alethic.Seq.Operator/LookupService.cs b/src/Alethic.Seq.Operator/LookupService.cs index a7ed66d..707cec2 100644 --- a/src/Alethic.Seq.Operator/LookupService.cs +++ b/src/Alethic.Seq.Operator/LookupService.cs @@ -152,11 +152,11 @@ internal static bool IsCrossNamespace(V1SecretReference secretRef, string ownerN /// /// /// - public async Task ResolveInstanceAsync(string name, string namespaceName, CancellationToken cancellationToken) + public async Task ResolveInstanceAsync(string name, string namespaceName, CancellationToken cancellationToken) { return await _cache.GetOrCreateAsync((typeof(LookupService), nameof(ResolveInstanceAsync), name, namespaceName), async entry => { - var ns = await _kube.GetAsync(name, namespaceName, cancellationToken: cancellationToken); + var ns = await _kube.GetAsync(name, namespaceName, cancellationToken: cancellationToken); entry.SetAbsoluteExpiration(TimeSpan.FromSeconds(30)); return ns; }); @@ -169,7 +169,7 @@ internal static bool IsCrossNamespace(V1SecretReference secretRef, string ownerN /// /// /// - public async Task ResolveInstanceRefAsync(V1alpha1InstanceReference? instanceRef, string defaultNamespace, CancellationToken cancellationToken) + public async Task ResolveInstanceRefAsync(V1InstanceReference? instanceRef, string defaultNamespace, CancellationToken cancellationToken) { if (instanceRef is null) return null; diff --git a/src/Alethic.Seq.Operator/Program.cs b/src/Alethic.Seq.Operator/Program.cs index 0667871..75f5de2 100644 --- a/src/Alethic.Seq.Operator/Program.cs +++ b/src/Alethic.Seq.Operator/Program.cs @@ -25,7 +25,7 @@ public static Task Main(string[] args) // KubeOps would otherwise attach every registered finalizer on each reconciliation, including the // retired identifiers that stay registered only so entities already carrying them can drain. Finalizer - // attachment is owned by V1alpha1Controller instead; see EntityFinalizers. + // attachment is owned by V1Controller instead; see EntityFinalizers. s.AutoAttachFinalizers = false; }).RegisterComponents(); diff --git a/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicy.cs b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicy.cs new file mode 100644 index 0000000..3adb657 --- /dev/null +++ b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicy.cs @@ -0,0 +1,20 @@ +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace Alethic.Seq.Operator.RetentionPolicy +{ + + [EntityScope(EntityScope.Namespaced)] + [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1", Kind = "RetentionPolicy")] + [StorageVersion] + [KubernetesEntityShortNames("seqretentionpolicy")] + public partial class V1RetentionPolicy : + CustomKubernetesEntity, + V1InstanceEntity + { + + } + +} diff --git a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyController.cs b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyController.cs similarity index 67% rename from src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyController.cs rename to src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyController.cs index bd2f357..d4d5685 100644 --- a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyController.cs +++ b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyController.cs @@ -23,13 +23,13 @@ namespace Alethic.Seq.Operator.RetentionPolicy { - [EntityRbac(typeof(V1alpha1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] - [EntityRbac(typeof(V1alpha1RetentionPolicy), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] + [EntityRbac(typeof(V1RetentionPolicy), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.All)] [EntityRbac(typeof(Eventsv1Event), Verbs = RbacVerb.All)] - public partial class V1alpha1RetentionPolicyController : - V1alpha1InstanceEntityController, - IEntityController + public partial class V1RetentionPolicyController : + V1InstanceEntityController, + IEntityController { /// @@ -39,7 +39,7 @@ public partial class V1alpha1RetentionPolicyController : /// /// /// - public V1alpha1RetentionPolicyController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1RetentionPolicyController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -49,13 +49,13 @@ public V1alpha1RetentionPolicyController(IKubernetesClient kube, IMemoryCache ca protected override string EntityTypeName => "RetentionPolicy"; /// - protected override Task CanAttachFromAsync(V1alpha1Instance instance, V1alpha1RetentionPolicy entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.RetentionPolicies?.Attach, cancellationToken); + protected override Task CanAttachFromAsync(V1Instance instance, V1RetentionPolicy entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.RetentionPolicies?.Attach, cancellationToken); /// - protected override Task CanCreateFromAsync(V1alpha1Instance instance, V1alpha1RetentionPolicy entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.RetentionPolicies?.Create, cancellationToken); + protected override Task CanCreateFromAsync(V1Instance instance, V1RetentionPolicy entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.RetentionPolicies?.Create, cancellationToken); /// - protected override async Task GetAsync(V1alpha1RetentionPolicy entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task GetAsync(V1RetentionPolicy entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) { try { @@ -68,13 +68,13 @@ public V1alpha1RetentionPolicyController(IKubernetesClient kube, IMemoryCache ca } /// - protected override async Task ValidateUpdateAsync(V1alpha1Instance instance, V1alpha1RetentionPolicy entity, RetentionPolicyConf? conf, CancellationToken cancellationToken) + protected override async Task ValidateUpdateAsync(V1Instance instance, V1RetentionPolicy entity, RetentionPolicyConf? conf, CancellationToken cancellationToken) { return null; } /// - protected override async Task CreateAsync(V1alpha1Instance instance, V1alpha1RetentionPolicy entity, SeqConnection api, RetentionPolicyConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task CreateAsync(V1Instance instance, V1RetentionPolicy entity, SeqConnection api, RetentionPolicyConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} creating RetentionPolicy in Seq.", EntityTypeName); var self = await api.RetentionPolicies.AddAsync(ApplyToApi(new RetentionPolicyEntity(), conf, null), cancellationToken); @@ -83,7 +83,7 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a } /// - protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1RetentionPolicy entity, SeqConnection api, string id, RetentionPolicyInfo? info, RetentionPolicyConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task UpdateAsync(V1Instance instance, V1RetentionPolicy entity, SeqConnection api, string id, RetentionPolicyInfo? info, RetentionPolicyConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} updating RetentionPolicy in Seq with id: {Id}", EntityTypeName, id); await api.RetentionPolicies.UpdateAsync(ApplyToApi(await api.RetentionPolicies.FindAsync(id, cancellationToken), conf, info), cancellationToken); @@ -91,7 +91,7 @@ protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1Ret } /// - protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) + protected override async Task DeleteAsync(V1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} deleting RetentionPolicy from Seq with ID: {Id} (reason: Kubernetes entity deleted)", EntityTypeName, id); await api.RetentionPolicies.RemoveAsync(await api.RetentionPolicies.FindAsync(id, cancellationToken), cancellationToken); diff --git a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicySpec.cs b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicySpec.cs similarity index 66% rename from src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicySpec.cs rename to src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicySpec.cs index 2824f5f..0d08969 100644 --- a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicySpec.cs +++ b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicySpec.cs @@ -5,12 +5,12 @@ namespace Alethic.Seq.Operator.RetentionPolicy { - public class V1alpha1RetentionPolicySpec : V1alpha1InstanceEntitySpec + public class V1RetentionPolicySpec : V1InstanceEntitySpec { [JsonPropertyName("instanceRef")] [Required] - public V1alpha1InstanceReference? InstanceRef { get; set; } + public V1InstanceReference? InstanceRef { get; set; } [JsonPropertyName("conf")] [Required] diff --git a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyStatus.cs b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyStatus.cs similarity index 63% rename from src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyStatus.cs rename to src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyStatus.cs index a4e1994..7d5aa25 100644 --- a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicyStatus.cs +++ b/src/Alethic.Seq.Operator/RetentionPolicy/V1RetentionPolicyStatus.cs @@ -4,7 +4,7 @@ namespace Alethic.Seq.Operator.RetentionPolicy { - public class V1alpha1RetentionPolicyStatus : V1alpha1InstanceEntityStatus + public class V1RetentionPolicyStatus : V1InstanceEntityStatus { [JsonPropertyName("id")] @@ -14,7 +14,7 @@ public class V1alpha1RetentionPolicyStatus : V1alpha1InstanceEntityStatus Conditions { get; set; } = new List(); + public IList Conditions { get; set; } = new List(); } diff --git a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicy.cs b/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicy.cs index da444a1..61903ee 100644 --- a/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicy.cs +++ b/src/Alethic.Seq.Operator/RetentionPolicy/V1alpha1RetentionPolicy.cs @@ -6,12 +6,20 @@ namespace Alethic.Seq.Operator.RetentionPolicy { + /// + /// The deprecated v1alpha1 version of . It shares the spec and status types, so the two + /// schemas are identical and the API server converts between them by relabelling apiVersion alone; no + /// conversion webhook is needed. Exists only so the CustomResourceDefinition keeps serving v1alpha1 to + /// existing clients: the operator works with , which is also the storage version. + /// + /// + /// Never give this type properties or attributes that change its schema. A schema that differs from v1 would + /// need a conversion webhook, and objects written through one version would lose fields when read through the other. + /// [EntityScope(EntityScope.Namespaced)] [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1alpha1", Kind = "RetentionPolicy")] [KubernetesEntityShortNames("seqretentionpolicy")] - public partial class V1alpha1RetentionPolicy : - CustomKubernetesEntity, - V1alpha1InstanceEntity + public class V1alpha1RetentionPolicy : CustomKubernetesEntity { } diff --git a/src/Alethic.Seq.Operator/Signals/V1Signal.cs b/src/Alethic.Seq.Operator/Signals/V1Signal.cs new file mode 100644 index 0000000..23a2a34 --- /dev/null +++ b/src/Alethic.Seq.Operator/Signals/V1Signal.cs @@ -0,0 +1,20 @@ +using k8s.Models; + +using KubeOps.Abstractions.Entities; +using KubeOps.Abstractions.Entities.Attributes; + +namespace Alethic.Seq.Operator.Signals +{ + + [EntityScope(EntityScope.Namespaced)] + [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1", Kind = "Signal")] + [StorageVersion] + [KubernetesEntityShortNames("seqsignal")] + public partial class V1Signal : + CustomKubernetesEntity, + V1InstanceEntity + { + + } + +} diff --git a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalController.cs b/src/Alethic.Seq.Operator/Signals/V1SignalController.cs similarity index 83% rename from src/Alethic.Seq.Operator/Signals/V1alpha1SignalController.cs rename to src/Alethic.Seq.Operator/Signals/V1SignalController.cs index 0b055d3..5e25edf 100644 --- a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalController.cs +++ b/src/Alethic.Seq.Operator/Signals/V1SignalController.cs @@ -27,13 +27,13 @@ namespace Alethic.Seq.Operator.Signals { - [EntityRbac(typeof(V1alpha1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] - [EntityRbac(typeof(V1alpha1Signal), Verbs = RbacVerb.All)] + [EntityRbac(typeof(V1Instance), Verbs = RbacVerb.List | RbacVerb.Get)] + [EntityRbac(typeof(V1Signal), Verbs = RbacVerb.All)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.All)] [EntityRbac(typeof(Eventsv1Event), Verbs = RbacVerb.All)] - public partial class V1alpha1SignalController : - V1alpha1InstanceEntityController, - IEntityController + public partial class V1SignalController : + V1InstanceEntityController, + IEntityController { /// @@ -44,7 +44,7 @@ public partial class V1alpha1SignalController : /// /// /// - public V1alpha1SignalController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1SignalController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -54,19 +54,19 @@ public V1alpha1SignalController(IKubernetesClient kube, IMemoryCache cache, Look protected override string EntityTypeName => "Signal"; /// - protected override Task CanAttachFromAsync(V1alpha1Instance instance, V1alpha1Signal entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Signals?.Attach, cancellationToken); + protected override Task CanAttachFromAsync(V1Instance instance, V1Signal entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Signals?.Attach, cancellationToken); /// - protected override Task CanCreateFromAsync(V1alpha1Instance instance, V1alpha1Signal entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Signals?.Create, cancellationToken); + protected override Task CanCreateFromAsync(V1Instance instance, V1Signal entity, CancellationToken cancellationToken) => instance.CheckPermissionAsync(Lookup, entity, false, p => p.Signals?.Create, cancellationToken); /// - protected override async Task FindAsync(V1alpha1Signal entity, SeqConnection api, V1alpha1SignalSpec spec, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task FindAsync(V1Signal entity, SeqConnection api, V1SignalSpec spec, string defaultNamespace, CancellationToken cancellationToken) { return null; } /// - protected override async Task GetAsync(V1alpha1Signal entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task GetAsync(V1Signal entity, SeqConnection api, string id, string defaultNamespace, CancellationToken cancellationToken) { try { @@ -79,13 +79,13 @@ public V1alpha1SignalController(IKubernetesClient kube, IMemoryCache cache, Look } /// - protected override async Task ValidateUpdateAsync(V1alpha1Instance instance, V1alpha1Signal entity, SignalConf? conf, CancellationToken cancellationToken) + protected override async Task ValidateUpdateAsync(V1Instance instance, V1Signal entity, SignalConf? conf, CancellationToken cancellationToken) { return null; } /// - protected override async Task CreateAsync(V1alpha1Instance instance, V1alpha1Signal entity, SeqConnection api, SignalConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task CreateAsync(V1Instance instance, V1Signal entity, SeqConnection api, SignalConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} creating Signal in Seq.", EntityTypeName); @@ -99,7 +99,7 @@ protected override async Task CreateAsync(V1alpha1Instance instance, V1a } /// - protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1Signal entity, SeqConnection api, string id, SignalInfo? info, SignalConf? conf, string defaultNamespace, CancellationToken cancellationToken) + protected override async Task UpdateAsync(V1Instance instance, V1Signal entity, SeqConnection api, string id, SignalInfo? info, SignalConf? conf, string defaultNamespace, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} updating Signal in Seq with id: {Id}", EntityTypeName, id); @@ -111,7 +111,7 @@ protected override async Task UpdateAsync(V1alpha1Instance instance, V1alpha1Sig } /// - protected override async Task DeleteAsync(V1alpha1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) + protected override async Task DeleteAsync(V1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken) { Logger.LogInformation("{EntityTypeName} deleting Signal from Seq with ID: {Id} (reason: Kubernetes entity deleted)", EntityTypeName, id); await api.Signals.RemoveAsync(await api.Signals.FindAsync(id, cancellationToken), cancellationToken); diff --git a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalSpec.cs b/src/Alethic.Seq.Operator/Signals/V1SignalSpec.cs similarity index 68% rename from src/Alethic.Seq.Operator/Signals/V1alpha1SignalSpec.cs rename to src/Alethic.Seq.Operator/Signals/V1SignalSpec.cs index 4a1c736..0334348 100644 --- a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalSpec.cs +++ b/src/Alethic.Seq.Operator/Signals/V1SignalSpec.cs @@ -5,12 +5,12 @@ namespace Alethic.Seq.Operator.Signals { - public class V1alpha1SignalSpec : V1alpha1InstanceEntitySpec + public class V1SignalSpec : V1InstanceEntitySpec { [JsonPropertyName("instanceRef")] [Required] - public V1alpha1InstanceReference? InstanceRef { get; set; } + public V1InstanceReference? InstanceRef { get; set; } [JsonPropertyName("conf")] [Required] diff --git a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalStatus.cs b/src/Alethic.Seq.Operator/Signals/V1SignalStatus.cs similarity index 64% rename from src/Alethic.Seq.Operator/Signals/V1alpha1SignalStatus.cs rename to src/Alethic.Seq.Operator/Signals/V1SignalStatus.cs index 8d602e5..8623ff3 100644 --- a/src/Alethic.Seq.Operator/Signals/V1alpha1SignalStatus.cs +++ b/src/Alethic.Seq.Operator/Signals/V1SignalStatus.cs @@ -4,7 +4,7 @@ namespace Alethic.Seq.Operator.Signals { - public class V1alpha1SignalStatus : V1alpha1InstanceEntityStatus + public class V1SignalStatus : V1InstanceEntityStatus { [JsonPropertyName("id")] @@ -14,7 +14,7 @@ public class V1alpha1SignalStatus : V1alpha1InstanceEntityStatus public SignalInfo? Info { get; set; } [JsonPropertyName("conditions")] - public IList Conditions { get; set; } = new List(); + public IList Conditions { get; set; } = new List(); } diff --git a/src/Alethic.Seq.Operator/Signals/V1alpha1Signal.cs b/src/Alethic.Seq.Operator/Signals/V1alpha1Signal.cs index fad1821..cbe37bf 100644 --- a/src/Alethic.Seq.Operator/Signals/V1alpha1Signal.cs +++ b/src/Alethic.Seq.Operator/Signals/V1alpha1Signal.cs @@ -6,12 +6,20 @@ namespace Alethic.Seq.Operator.Signals { + /// + /// The deprecated v1alpha1 version of . It shares the spec and status types, so the two + /// schemas are identical and the API server converts between them by relabelling apiVersion alone; no + /// conversion webhook is needed. Exists only so the CustomResourceDefinition keeps serving v1alpha1 to + /// existing clients: the operator works with , which is also the storage version. + /// + /// + /// Never give this type properties or attributes that change its schema. A schema that differs from v1 would + /// need a conversion webhook, and objects written through one version would lose fields when read through the other. + /// [EntityScope(EntityScope.Namespaced)] [KubernetesEntity(Group = "seq.k8s.datalust.co", ApiVersion = "v1alpha1", Kind = "Signal")] [KubernetesEntityShortNames("seqsignal")] - public partial class V1alpha1Signal : - CustomKubernetesEntity, - V1alpha1InstanceEntity + public class V1alpha1Signal : CustomKubernetesEntity { } diff --git a/src/Alethic.Seq.Operator/V1alpha1Controller.cs b/src/Alethic.Seq.Operator/V1Controller.cs similarity index 94% rename from src/Alethic.Seq.Operator/V1alpha1Controller.cs rename to src/Alethic.Seq.Operator/V1Controller.cs index 109c65c..de9c76c 100644 --- a/src/Alethic.Seq.Operator/V1alpha1Controller.cs +++ b/src/Alethic.Seq.Operator/V1Controller.cs @@ -28,7 +28,7 @@ namespace Alethic.Seq.Operator { - public abstract class V1alpha1Controller + public abstract class V1Controller { readonly IKubernetesClient _kube; @@ -44,7 +44,7 @@ public abstract class V1alpha1Controller /// /// /// - public V1alpha1Controller(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) + public V1Controller(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) { _cache = cache ?? throw new ArgumentNullException(nameof(cache)); _lookup = lookup ?? throw new ArgumentNullException(nameof(lookup)); @@ -83,10 +83,10 @@ public V1alpha1Controller(IKubernetesClient kube, IMemoryCache cache, LookupServ [EntityRbac(typeof(V1Namespace), Verbs = RbacVerb.List | RbacVerb.Get | RbacVerb.Watch)] [EntityRbac(typeof(V1Secret), Verbs = RbacVerb.List | RbacVerb.Get | RbacVerb.Watch)] [EntityRbac(typeof(Eventsv1Event), Verbs = RbacVerb.Create)] - public abstract class V1alpha1Controller : V1alpha1Controller, IEntityController - where TEntity : IKubernetesObject, V1alpha1Entity - where TSpec : V1alpha1EntitySpec - where TStatus : V1alpha1EntityStatus + public abstract class V1Controller : V1Controller, IEntityController + where TEntity : IKubernetesObject, V1Entity + where TSpec : V1EntitySpec + where TStatus : V1EntityStatus where TConf : class where TInfo : class { @@ -99,7 +99,7 @@ public abstract class V1alpha1Controller /// /// /// - public V1alpha1Controller(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1Controller(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -139,7 +139,7 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - async Task CreateSeqLoginConnectionAsync(V1alpha1Instance instance, string endpoint, V1SecretReference secretRef, CancellationToken cancellationToken) + async Task CreateSeqLoginConnectionAsync(V1Instance instance, string endpoint, V1SecretReference secretRef, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(secretRef.Name)) { @@ -247,7 +247,7 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - async Task CreateSeqLoginConnectionAsync(V1alpha1Instance instance, string endpoint, InstanceLoginRemoteAuthenticationSpec loginDef, CancellationToken cancellationToken) + async Task CreateSeqLoginConnectionAsync(V1Instance instance, string endpoint, InstanceLoginRemoteAuthenticationSpec loginDef, CancellationToken cancellationToken) { var secretRef = loginDef.SecretRef; if (secretRef == null) @@ -268,7 +268,7 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - async Task CreateSeqTokenConnectionAsync(V1alpha1Instance instance, string endpoint, V1SecretReference secretRef, CancellationToken cancellationToken) + async Task CreateSeqTokenConnectionAsync(V1Instance instance, string endpoint, V1SecretReference secretRef, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(secretRef.Name)) { @@ -311,7 +311,7 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - async Task CreateSeqTokenConnectionAsync(V1alpha1Instance instance, string endpoint, InstanceTokenRemoteAuthenticationSpec tokenDef, CancellationToken cancellationToken) + async Task CreateSeqTokenConnectionAsync(V1Instance instance, string endpoint, InstanceTokenRemoteAuthenticationSpec tokenDef, CancellationToken cancellationToken) { var secretRef = tokenDef.SecretRef; if (secretRef is null) @@ -330,7 +330,7 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - async Task CreateSeqConnectionAsync(V1alpha1Instance instance, InstanceRemoteSpec remote, CancellationToken cancellationToken) + async Task CreateSeqConnectionAsync(V1Instance instance, InstanceRemoteSpec remote, CancellationToken cancellationToken) { var endpoint = remote.Endpoint; if (string.IsNullOrWhiteSpace(endpoint)) @@ -368,9 +368,9 @@ async Task TestConnectionAsync(SeqConnection connection, CancellationToken /// /// /// - protected async Task GetInstanceConnectionAsync(V1alpha1Instance instance, CancellationToken cancellationToken) + protected async Task GetInstanceConnectionAsync(V1Instance instance, CancellationToken cancellationToken) { - var connection = await Cache.GetOrCreateAsync((typeof(V1alpha1Controller<,,,,>), nameof(GetInstanceConnectionAsync), instance.Namespace(), instance.Name()), async entry => + var connection = await Cache.GetOrCreateAsync((typeof(V1Controller<,,,,>), nameof(GetInstanceConnectionAsync), instance.Namespace(), instance.Name()), async entry => { // use specified remote connection(s) if present if (instance.Spec.Remote is not null) @@ -544,7 +544,7 @@ public async Task> ReconcileAsync(TEntity entity, var ready = entity.Status.Conditions.FirstOrDefault(i => i.Type == "Ready"); if (ready is null) { - entity.Status.Conditions.Add(ready = new V1alpha1Condition() { Type = "Ready", Status = "False" }); + entity.Status.Conditions.Add(ready = new V1EntityCondition() { Type = "Ready", Status = "False" }); entity = await Kube.UpdateStatusAsync(entity, cancellationToken); } @@ -552,7 +552,7 @@ public async Task> ReconcileAsync(TEntity entity, var healthy = entity.Status.Conditions.FirstOrDefault(i => i.Type == "Healthy"); if (healthy is null) { - entity.Status.Conditions.Add(healthy = new V1alpha1Condition() { Type = "Healthy", Status = "False" }); + entity.Status.Conditions.Add(healthy = new V1EntityCondition() { Type = "Healthy", Status = "False" }); entity = await Kube.UpdateStatusAsync(entity, cancellationToken); } @@ -646,7 +646,7 @@ async Task UpdateStatusAsync(TEntity entity, string type, string status { var condition = entity.Status.Conditions.FirstOrDefault(i => i.Type == type); if (condition is null) - entity.Status.Conditions.Add(condition = new V1alpha1Condition()); + entity.Status.Conditions.Add(condition = new V1EntityCondition()); condition.Type = type; condition.Status = status; diff --git a/src/Alethic.Seq.Operator/V1alpha1Entity.cs b/src/Alethic.Seq.Operator/V1Entity.cs similarity index 68% rename from src/Alethic.Seq.Operator/V1alpha1Entity.cs rename to src/Alethic.Seq.Operator/V1Entity.cs index da52ac8..ee8dcac 100644 --- a/src/Alethic.Seq.Operator/V1alpha1Entity.cs +++ b/src/Alethic.Seq.Operator/V1Entity.cs @@ -1,9 +1,9 @@ namespace Alethic.Seq.Operator { - public interface V1alpha1Entity - where TSpec : V1alpha1EntitySpec - where TStatus : V1alpha1EntityStatus + public interface V1Entity + where TSpec : V1EntitySpec + where TStatus : V1EntityStatus where TConf : class where TInfo : class { diff --git a/src/Alethic.Seq.Operator/V1alpha1Condition.cs b/src/Alethic.Seq.Operator/V1EntityCondition.cs similarity index 95% rename from src/Alethic.Seq.Operator/V1alpha1Condition.cs rename to src/Alethic.Seq.Operator/V1EntityCondition.cs index 3766ee6..72ce1e6 100644 --- a/src/Alethic.Seq.Operator/V1alpha1Condition.cs +++ b/src/Alethic.Seq.Operator/V1EntityCondition.cs @@ -3,7 +3,7 @@ namespace Alethic.Seq.Operator { - public class V1alpha1Condition + public class V1EntityCondition { /// diff --git a/src/Alethic.Seq.Operator/V1alpha1EntitySpec.cs b/src/Alethic.Seq.Operator/V1EntitySpec.cs similarity index 82% rename from src/Alethic.Seq.Operator/V1alpha1EntitySpec.cs rename to src/Alethic.Seq.Operator/V1EntitySpec.cs index 3e13553..e888e54 100644 --- a/src/Alethic.Seq.Operator/V1alpha1EntitySpec.cs +++ b/src/Alethic.Seq.Operator/V1EntitySpec.cs @@ -1,7 +1,7 @@ namespace Alethic.Seq.Operator { - public interface V1alpha1EntitySpec + public interface V1EntitySpec where TConf : class { diff --git a/src/Alethic.Seq.Operator/V1alpha1EntityStatus.cs b/src/Alethic.Seq.Operator/V1EntityStatus.cs similarity index 66% rename from src/Alethic.Seq.Operator/V1alpha1EntityStatus.cs rename to src/Alethic.Seq.Operator/V1EntityStatus.cs index bd7543d..b992647 100644 --- a/src/Alethic.Seq.Operator/V1alpha1EntityStatus.cs +++ b/src/Alethic.Seq.Operator/V1EntityStatus.cs @@ -6,13 +6,13 @@ namespace Alethic.Seq.Operator { - public interface V1alpha1EntityStatus + public interface V1EntityStatus where TInfo : class { TInfo? Info { get; set; } - IList Conditions { get; set; } + IList Conditions { get; set; } } diff --git a/src/Alethic.Seq.Operator/V1InstanceEntity.cs b/src/Alethic.Seq.Operator/V1InstanceEntity.cs new file mode 100644 index 0000000..4eca82a --- /dev/null +++ b/src/Alethic.Seq.Operator/V1InstanceEntity.cs @@ -0,0 +1,13 @@ +namespace Alethic.Seq.Operator +{ + + public interface V1InstanceEntity : V1Entity + where TSpec : V1InstanceEntitySpec + where TStatus : V1InstanceEntityStatus + where TConf : class + where TInfo : class + { + + } + +} diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs b/src/Alethic.Seq.Operator/V1InstanceEntityController.cs similarity index 90% rename from src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs rename to src/Alethic.Seq.Operator/V1InstanceEntityController.cs index b6d0417..acb2d60 100644 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs +++ b/src/Alethic.Seq.Operator/V1InstanceEntityController.cs @@ -20,11 +20,11 @@ namespace Alethic.Seq.Operator { - public abstract class V1alpha1InstanceEntityController : - V1alpha1Controller - where TEntity : IKubernetesObject, V1alpha1InstanceEntity - where TSpec : V1alpha1InstanceEntitySpec - where TStatus : V1alpha1InstanceEntityStatus + public abstract class V1InstanceEntityController : + V1Controller + where TEntity : IKubernetesObject, V1InstanceEntity + where TSpec : V1InstanceEntitySpec + where TStatus : V1InstanceEntityStatus where TConf : class where TInfo : class { @@ -37,7 +37,7 @@ public abstract class V1alpha1InstanceEntityController /// /// - public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : + public V1InstanceEntityController(IKubernetesClient kube, IMemoryCache cache, LookupService lookup, IOptions options, ILogger logger) : base(kube, cache, lookup, options, logger) { @@ -49,7 +49,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected abstract Task CanAttachFromAsync(V1alpha1Instance instance, TEntity entity, CancellationToken cancellationToken); + protected abstract Task CanAttachFromAsync(V1Instance instance, TEntity entity, CancellationToken cancellationToken); /// /// Returns true if the given entity can create new Seq objects in the given instance. @@ -57,7 +57,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected abstract Task CanCreateFromAsync(V1alpha1Instance instance, TEntity entity, CancellationToken cancellationToken); + protected abstract Task CanCreateFromAsync(V1Instance instance, TEntity entity, CancellationToken cancellationToken); /// /// Gets the for the entity with the given or returns null. @@ -91,7 +91,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected virtual async Task ValidateCreateAsync(V1alpha1Instance instance, TEntity entity, TConf? conf, CancellationToken cancellationToken) + protected virtual async Task ValidateCreateAsync(V1Instance instance, TEntity entity, TConf? conf, CancellationToken cancellationToken) { return await ValidateUpdateAsync(instance, entity, conf, cancellationToken); } @@ -103,7 +103,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected abstract Task ValidateUpdateAsync(V1alpha1Instance instance, TEntity entity, TConf? conf, CancellationToken cancellationToken); + protected abstract Task ValidateUpdateAsync(V1Instance instance, TEntity entity, TConf? conf, CancellationToken cancellationToken); /// /// Attempts to perform a creation through the API. If successful returns the new ID value. @@ -115,7 +115,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected abstract Task CreateAsync(V1alpha1Instance instance, TEntity entity, SeqConnection api, TConf? conf, string defaultNamespace, CancellationToken cancellationToken); + protected abstract Task CreateAsync(V1Instance instance, TEntity entity, SeqConnection api, TConf? conf, string defaultNamespace, CancellationToken cancellationToken); /// /// Attempts to perform an update through the API. @@ -129,7 +129,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected abstract Task UpdateAsync(V1alpha1Instance instance, TEntity entity, SeqConnection api, string id, TInfo? info, TConf? conf, string defaultNamespace, CancellationToken cancellationToken); + protected abstract Task UpdateAsync(V1Instance instance, TEntity entity, SeqConnection api, string id, TInfo? info, TConf? conf, string defaultNamespace, CancellationToken cancellationToken); /// /// Removes an object just created in Seq whose ID could not be recorded, so that the retry does not leave it @@ -142,7 +142,7 @@ public V1alpha1InstanceEntityController(IKubernetesClient kube, IMemoryCache cac /// /// /// - protected async Task DeleteOrphanAsync(V1alpha1Instance instance, TEntity entity, SeqConnection api, string id, Exception cause, CancellationToken cancellationToken) + protected async Task DeleteOrphanAsync(V1Instance instance, TEntity entity, SeqConnection api, string id, Exception cause, CancellationToken cancellationToken) { Logger.LogWarning(cause, "{EntityTypeName} {Namespace}/{Name} created {Id} in Seq but could not record it; removing it so the retry does not create a duplicate.", EntityTypeName, entity.Namespace(), entity.Name(), id); @@ -278,7 +278,7 @@ protected virtual Task ApplyStatusAsync(TEntity entity, SeqConnection api, TInfo /// /// /// - protected abstract Task DeleteAsync(V1alpha1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken); + protected abstract Task DeleteAsync(V1Instance instance, SeqConnection api, string id, CancellationToken cancellationToken); /// public override sealed async Task> DeletedAsync(TEntity entity, CancellationToken cancellationToken) @@ -301,7 +301,7 @@ public override sealed async Task> DeletedAsync(TE } // read past the lookup cache: a cached Instance may already be gone, or not yet show it is being deleted - var instance = await Kube.GetAsync(instanceName, instanceRef.Namespace ?? entity.Namespace(), cancellationToken: cancellationToken); + var instance = await Kube.GetAsync(instanceName, instanceRef.Namespace ?? entity.Namespace(), cancellationToken: cancellationToken); if (instance is null) { // without the Instance there are no credentials to reach Seq with; holding the finalizer would diff --git a/src/Alethic.Seq.Operator/V1InstanceEntitySpec.cs b/src/Alethic.Seq.Operator/V1InstanceEntitySpec.cs new file mode 100644 index 0000000..8a07cef --- /dev/null +++ b/src/Alethic.Seq.Operator/V1InstanceEntitySpec.cs @@ -0,0 +1,12 @@ +namespace Alethic.Seq.Operator +{ + + public interface V1InstanceEntitySpec : V1EntitySpec + where TConf : class + { + + V1InstanceReference? InstanceRef { get; set; } + + } + +} diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityStatus.cs b/src/Alethic.Seq.Operator/V1InstanceEntityStatus.cs similarity index 56% rename from src/Alethic.Seq.Operator/V1alpha1InstanceEntityStatus.cs rename to src/Alethic.Seq.Operator/V1InstanceEntityStatus.cs index 67a044f..13482fd 100644 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceEntityStatus.cs +++ b/src/Alethic.Seq.Operator/V1InstanceEntityStatus.cs @@ -1,7 +1,7 @@ namespace Alethic.Seq.Operator { - public interface V1alpha1InstanceEntityStatus : V1alpha1EntityStatus + public interface V1InstanceEntityStatus : V1EntityStatus where TConf : class { diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceReference.cs b/src/Alethic.Seq.Operator/V1InstanceReference.cs similarity index 93% rename from src/Alethic.Seq.Operator/V1alpha1InstanceReference.cs rename to src/Alethic.Seq.Operator/V1InstanceReference.cs index ac608b8..df7c1aa 100644 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceReference.cs +++ b/src/Alethic.Seq.Operator/V1InstanceReference.cs @@ -5,7 +5,7 @@ namespace Alethic.Seq.Operator { - public class V1alpha1InstanceReference + public class V1InstanceReference { [JsonPropertyName("namespace")] diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceEntity.cs b/src/Alethic.Seq.Operator/V1alpha1InstanceEntity.cs deleted file mode 100644 index cf25a1d..0000000 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceEntity.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Alethic.Seq.Operator -{ - - public interface V1alpha1InstanceEntity : V1alpha1Entity - where TSpec : V1alpha1InstanceEntitySpec - where TStatus : V1alpha1InstanceEntityStatus - where TConf : class - where TInfo : class - { - - } - -} diff --git a/src/Alethic.Seq.Operator/V1alpha1InstanceEntitySpec.cs b/src/Alethic.Seq.Operator/V1alpha1InstanceEntitySpec.cs deleted file mode 100644 index 609be44..0000000 --- a/src/Alethic.Seq.Operator/V1alpha1InstanceEntitySpec.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace Alethic.Seq.Operator -{ - - public interface V1alpha1InstanceEntitySpec : V1alpha1EntitySpec - where TConf : class - { - - V1alpha1InstanceReference? InstanceRef { get; set; } - - } - -} From 389f80ecbba4c8ccd416edb474b5d01105f820b1 Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:10:52 -0500 Subject: [PATCH 6/7] Add CLAUDE.md: no Claude attribution in commits or pull requests --- CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6e5e9eb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,11 @@ +# Instructions for Claude + +## Attribution + +Never attribute work to Claude in this repository. That means: + +- No `Co-Authored-By: Claude` (or any other Claude) trailer in commit messages. +- No "Generated with Claude Code" line, or any other mention of Claude, in pull request titles, descriptions or + comments. + +Commits and pull requests carry the author's name only. From 65234ef2fb566186bcf607d7742fdd44d926fd5d Mon Sep 17 00:00:00 2001 From: Jerome Haltom Date: Sun, 18 Oct 2026 11:17:16 -0500 Subject: [PATCH 7/7] Recognise objects owned through a v1alpha1 owner reference Every object the operator created before v1 (an Instance's StatefulSet, Service, ServiceAccount, login Secret and operator ApiKey, and each ApiKey's token Secret) carries an owner reference naming seq.k8s.datalust.co/v1alpha1. The Kubernetes client's IsOwnedBy compares apiVersion as well as UID, so once the operator read its Instances as v1 it no longer recognised any of them: a deployed Instance would fail every reconcile with a StatefulSet conflict. Ownership is now decided by UID, as the garbage collector does, and owner references are rewritten to v1 whenever the operator updates the object, so none outlive v1alpha1. --- .../OwnershipTests.cs | 91 +++++++++++++++++++ .../ApiKey/V1ApiKeyController.cs | 5 +- .../Instance/V1InstanceController.cs | 33 ++++--- .../OwnershipExtensions.cs | 53 +++++++++++ 4 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 src/Alethic.Seq.Operator.Tests/OwnershipTests.cs create mode 100644 src/Alethic.Seq.Operator/OwnershipExtensions.cs diff --git a/src/Alethic.Seq.Operator.Tests/OwnershipTests.cs b/src/Alethic.Seq.Operator.Tests/OwnershipTests.cs new file mode 100644 index 0000000..da4ca55 --- /dev/null +++ b/src/Alethic.Seq.Operator.Tests/OwnershipTests.cs @@ -0,0 +1,91 @@ +using System.Collections.Generic; + +using Alethic.Seq.Operator.Instance; + +using k8s; +using k8s.Models; + +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Alethic.Seq.Operator.Tests +{ + + /// + /// Objects the operator created before v1 carry owner references naming seq.k8s.datalust.co/v1alpha1. The + /// operator now reads its Instances as v1, and must still recognise those objects as its own. + /// + [TestClass] + public class OwnershipTests + { + + const string Uid = "6a4c2f9e-0000-4000-8000-000000000001"; + + static V1Instance Instance() => new V1Instance() + { + Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq", Uid = Uid }, + }.Initialize(); + + static V1Secret OwnedBy(string apiVersion, string uid) => new() + { + Metadata = new V1ObjectMeta() + { + NamespaceProperty = "seq", + Name = "seq-operator-login", + OwnerReferences = new List() + { + new() { ApiVersion = apiVersion, Kind = "Instance", Name = "seq", Uid = uid }, + }, + }, + }; + + /// + /// Documents why exists: the client's own check fails here. + /// + [TestMethod] + public void ClientIsOwnedByRejectsAnOlderApiVersion() + { + Assert.IsFalse(OwnedBy("seq.k8s.datalust.co/v1alpha1", Uid).IsOwnedBy(Instance())); + } + + [TestMethod] + public void RecognisesReferencesWrittenUnderV1alpha1() + { + Assert.IsTrue(OwnedBy("seq.k8s.datalust.co/v1alpha1", Uid).IsOwnedByEntity(Instance())); + } + + [TestMethod] + public void RecognisesReferencesWrittenUnderV1() + { + Assert.IsTrue(OwnedBy("seq.k8s.datalust.co/v1", Uid).IsOwnedByEntity(Instance())); + } + + [TestMethod] + public void RejectsAnotherOwner() + { + Assert.IsFalse(OwnedBy("seq.k8s.datalust.co/v1", "6a4c2f9e-0000-4000-8000-000000000002").IsOwnedByEntity(Instance())); + Assert.IsFalse(new V1Secret() { Metadata = new V1ObjectMeta() { Name = "unowned" } }.IsOwnedByEntity(Instance())); + } + + [TestMethod] + public void RefreshRewritesTheOwnersReferenceToV1() + { + var secret = OwnedBy("seq.k8s.datalust.co/v1alpha1", Uid); + secret.RefreshOwnerReference(Instance()); + + Assert.AreEqual("seq.k8s.datalust.co/v1", secret.Metadata.OwnerReferences[0].ApiVersion); + Assert.AreEqual(Uid, secret.Metadata.OwnerReferences[0].Uid); + Assert.IsTrue(secret.IsOwnedBy(Instance())); + } + + [TestMethod] + public void RefreshLeavesOtherOwnersAlone() + { + var secret = OwnedBy("example.com/v1beta1", "6a4c2f9e-0000-4000-8000-000000000002"); + secret.RefreshOwnerReference(Instance()); + + Assert.AreEqual("example.com/v1beta1", secret.Metadata.OwnerReferences[0].ApiVersion); + } + + } + +} diff --git a/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs index add6aac..49b50be 100644 --- a/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs +++ b/src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs @@ -301,7 +301,7 @@ async Task ApplySecretAsync(V1ApiKey entity, string? endpoint, string? token, st var secret = await FindRelatedAsync(entity, cancellationToken); // we have an existing secret, owned by us - if (secret is not null && secret.IsOwnedBy(entity)) + if (secret is not null && secret.IsOwnedByEntity(entity)) { // existing secret does not match our specification if (secret.Name() != secretName || secret.Namespace() != secretNamespace) @@ -329,9 +329,10 @@ async Task ApplySecretAsync(V1ApiKey entity, string? endpoint, string? token, st } // only apply actual values if we are the owner - if (secret.IsOwnedBy(entity)) + if (secret.IsOwnedByEntity(entity)) { Logger.LogInformation("{EntityTypeName} {EntityNamespace}/{EntityName} referenced secret {SecretName}: updating.", EntityTypeName, entity.Namespace(), entity.Name(), secret.Name()); + secret.RefreshOwnerReference(entity); secret.StringData ??= new Dictionary(); // ensure the endpoint exists, possible empty, if we're retrieving an existing ApiKey diff --git a/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs b/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs index 3eace30..4121498 100644 --- a/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs @@ -206,7 +206,7 @@ async Task ReconcileDeploymentAsync(V1Instance instance, InstanceDep var operatorLoginSecret = await GetRelatedObject(instance, "operator-login", cancellationToken); // no deployment, we have an existing admin secret owned by us, delete - if (deployment is null && operatorLoginSecret is not null && operatorLoginSecret.IsOwnedBy(instance)) + if (deployment is null && operatorLoginSecret is not null && operatorLoginSecret.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(operatorLoginSecret, cancellationToken); operatorLoginSecret = null; @@ -221,7 +221,7 @@ async Task ReconcileDeploymentAsync(V1Instance instance, InstanceDep throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: LoginSecret must be in same namespace as Instance."); // we have an existing secret, owned by us - if (operatorLoginSecret is not null && operatorLoginSecret.IsOwnedBy(instance)) + if (operatorLoginSecret is not null && operatorLoginSecret.IsOwnedByEntity(instance)) { // existing secret does not match our specification if (operatorLoginSecret.Name() != operatorLoginSecretName || operatorLoginSecret.Namespace() != operatorLoginSecretNamespace) @@ -248,7 +248,7 @@ async Task ReconcileDeploymentAsync(V1Instance instance, InstanceDep } // we have a secret at this point, and it is owned by us, we can ensure the login information is set to defaults - if (operatorLoginSecret.IsOwnedBy(instance)) + if (operatorLoginSecret.IsOwnedByEntity(instance)) { ApplyDeploymentLoginSecret(instance, deployment, operatorLoginSecret); ApplyDeploymentResource(instance, deployment, "operator-login", operatorLoginSecret); @@ -298,7 +298,7 @@ V1Secret ApplyDeploymentLoginSecret(V1Instance instance, InstanceDeploymentSpec var operatorTokenApiKey = await GetRelatedObject(instance, "operator-token", cancellationToken); // no deployment, we have an existing operator apikey owned by us, delete - if (deployment is null && operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedBy(instance)) + if (deployment is null && operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(operatorTokenApiKey, cancellationToken); operatorTokenApiKey = null; @@ -313,7 +313,7 @@ V1Secret ApplyDeploymentLoginSecret(V1Instance instance, InstanceDeploymentSpec throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: ApiKey must be in same namespace as Instance."); // we have an existing secret, owned by us - if (operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedBy(instance)) + if (operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedByEntity(instance)) { // existing apikey does not match our specification if (operatorTokenApiKey.Name() != operatorTokenApiKeyName || operatorTokenApiKey.Namespace() != operatorTokenApiKeyNamespace) @@ -340,7 +340,7 @@ V1Secret ApplyDeploymentLoginSecret(V1Instance instance, InstanceDeploymentSpec } // we have a apikey at this point, and it is owned by us, we can ensure the login information is set to defaults - if (operatorTokenApiKey.IsOwnedBy(instance)) + if (operatorTokenApiKey.IsOwnedByEntity(instance)) { ApplyDeploymentAdminApiKey(instance, deployment, operatorTokenApiKey); ApplyDeploymentResource(instance, deployment, "operator-token", operatorTokenApiKey); @@ -381,7 +381,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec var serviceAccount = await GetRelatedObject(instance, "service-account", cancellationToken); // no deployment, we have an existing service account owned by us, delete - if (deployment is null && serviceAccount is not null && serviceAccount.IsOwnedBy(instance)) + if (deployment is null && serviceAccount is not null && serviceAccount.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(serviceAccount, cancellationToken); serviceAccount = null; @@ -396,7 +396,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: ServiceAccount must be in same namespace as Instance."); // we have an existing service account, owned by us - if (serviceAccount is not null && serviceAccount.IsOwnedBy(instance)) + if (serviceAccount is not null && serviceAccount.IsOwnedByEntity(instance)) { // existing service account does not match our specification if (serviceAccount.Name() != serviceAccountName || serviceAccount.Namespace() != serviceAccountNamespace) @@ -420,7 +420,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec } // we have a secret at this point, and it is owned by us, we can ensure the login information is set to defaults - if (serviceAccount.IsOwnedBy(instance)) + if (serviceAccount.IsOwnedByEntity(instance)) { ApplyDeploymentResource(instance, deployment, "service-account", serviceAccount); serviceAccount = await Kube.UpdateAsync(serviceAccount, cancellationToken); @@ -443,7 +443,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec var statefulSet = await GetRelatedObject(instance, "stateful-set", cancellationToken); // no deployment, we have an existing stateful set owned by us, delete - if (deployment is null && statefulSet is not null && statefulSet.IsOwnedBy(instance)) + if (deployment is null && statefulSet is not null && statefulSet.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(statefulSet, cancellationToken); statefulSet = null; @@ -465,7 +465,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: StatefulSet must be in same namespace as Instance."); // we have an existing stateful set, owned by us - if (statefulSet is not null && statefulSet.IsOwnedBy(instance)) + if (statefulSet is not null && statefulSet.IsOwnedByEntity(instance)) { // existing stateful set does not match our specification if (statefulSet.Name() != statefulSetName || statefulSet.Namespace() != statefulSetNamespace) @@ -495,7 +495,7 @@ V1ApiKey ApplyDeploymentAdminApiKey(V1Instance instance, InstanceDeploymentSpec } // we have a stateful set at this point, and it must be owned by us - if (statefulSet.IsOwnedBy(instance) == false) + if (statefulSet.IsOwnedByEntity(instance) == false) throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: StatefulSet conflict. Set is not owned by instance."); // update object if required @@ -660,7 +660,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1Instance instance, InstanceDeployment var service = await GetRelatedObject(instance, "service", cancellationToken); // no deployment, we have an existing service owned by us, delete - if (deployment is null && service is not null && service.IsOwnedBy(instance)) + if (deployment is null && service is not null && service.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(service, cancellationToken); service = null; @@ -675,7 +675,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1Instance instance, InstanceDeployment throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: Service must be in same namespace as Instance."); // we have an existing service, owned by us - if (service is not null && service.IsOwnedBy(instance)) + if (service is not null && service.IsOwnedByEntity(instance)) { // existing service does not match our specification if (service.Name() != serviceName || service.Namespace() != serviceNamespace) @@ -702,7 +702,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1Instance instance, InstanceDeployment } // we have a service at this point, and it must be owned by us - if (service.IsOwnedBy(instance) == false) + if (service.IsOwnedByEntity(instance) == false) throw new RetryException($"Instance {instance.Namespace()}/{instance.Name()} could not deploy: Service conflict. Set is not owned by instance."); // update object if required @@ -799,6 +799,9 @@ T ApplyDeploymentResource(V1Instance instance, InstanceDeploymentSpec deploym l["seq.k8s.datalust.co/instance"] = instance.Name(); l["seq.k8s.datalust.co/resource"] = resource; + // objects created before v1 reference the Instance as v1alpha1 + entity.RefreshOwnerReference(instance); + return entity; } diff --git a/src/Alethic.Seq.Operator/OwnershipExtensions.cs b/src/Alethic.Seq.Operator/OwnershipExtensions.cs new file mode 100644 index 0000000..b0974b0 --- /dev/null +++ b/src/Alethic.Seq.Operator/OwnershipExtensions.cs @@ -0,0 +1,53 @@ +using System.Linq; + +using k8s; +using k8s.Models; + +namespace Alethic.Seq.Operator +{ + + /// + /// Ownership checks that survive an API version change of the owner. + /// + /// + /// The Kubernetes client's IsOwnedBy compares an owner reference's apiVersion as well as its UID, so + /// objects created while their owner was read as v1alpha1 are not recognised once it is read as v1. + /// Ownership is decided by UID here, as Kubernetes' own garbage collector does. + /// + public static class OwnershipExtensions + { + + /// + /// Returns whether has an owner reference to , whatever API + /// version that reference names. + /// + /// + /// + /// + public static bool IsOwnedByEntity(this IMetadata obj, IKubernetesObject owner) + { + return owner.Uid() is { Length: > 0 } uid && obj.Metadata?.OwnerReferences?.Any(i => i.Uid == uid) == true; + } + + /// + /// Rewrites any owner reference to to name the API version the owner was read as, so + /// references written under an older version do not outlive it. + /// + /// + /// + /// + /// + public static T RefreshOwnerReference(this T obj, IKubernetesObject owner) + where T : IMetadata + { + if (owner.ApiVersion is { Length: > 0 } apiVersion && owner.Uid() is { Length: > 0 } uid && obj.Metadata?.OwnerReferences is { } references) + foreach (var reference in references) + if (reference.Uid == uid) + reference.ApiVersion = apiVersion; + + return obj; + } + + } + +}