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. diff --git a/README.md b/README.md index 099c573..0b2ecd5 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 `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 + +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,387 @@ 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/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` | `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`. + +### Deploying Seq + +An Instance without a `remote` section is deployed by the operator. This is the smallest one: + +```yaml +apiVersion: seq.k8s.datalust.co/v1 +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. -This is an example for connecting to and bootstrapping an existing Seq instance deployed with a SEQ_FIRSTRUN_ADMINPASSWORDHASH setting. +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/v1 +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 +apiVersion: seq.k8s.datalust.co/v1 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 +apiVersion: seq.k8s.datalust.co/v1 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 + clientSecretRef: + name: seq-oidc + key: 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`. + +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 +`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/v1 +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 ``` -apiVersion: seq.k8s.datalust.co/v1alpha1 -kind: Instance + +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 + +```yaml +apiVersion: seq.k8s.datalust.co/v1 +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/v1 +kind: RetentionPolicy +metadata: + name: thirty-days + namespace: logging +spec: + instanceRef: + name: seq + conf: + retentionTime: "30.00:00:00" +``` + +`retentionTime` is a .NET `TimeSpan`, written `d.hh:mm:ss`. + +### Which namespaces may use an Instance + +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`. +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: read + apiKeys: + create: true + setTitle: true + setRead: true + - namespaces: + from: All + apiKeys: + create: true + setTitle: true ``` -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. +The first entry keeps the Instance's own namespace fully privileged. Without it, listing other namespaces would +take that away. + +### Deletion + +Each resource carries a finalizer. Deleting an ApiKey, Signal, RetentionPolicy or Alert deletes the matching +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 -### Adopting the CustomResourceDefinitions (one time) +### 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 +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. -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. +### Secret references must stay in their own namespace -Releases installed before this change own their CRDs outside of Helm, and Helm refuses to take over a -resource it does not own: +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 +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 +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 +414,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 +429,44 @@ 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. + +## 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/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 new file mode 100644 index 0000000..c279499 --- /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 V1Instance Instance(params InstancePermission[]? permissions) => new() + { + Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, + Spec = new V1InstanceSpec() { 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(V1Instance instance, string ns, params ApiKeyPermission[] permissions) => + V1ApiKeyController.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 = 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)); + } + + /// + /// 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(V1ApiKeyController.IsPermissionGranted(new InstanceApiKeyPermissions() { SetProject = true }, ApiKeyPermission.Setup)); + Assert.IsTrue(V1ApiKeyController.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.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 new file mode 100644 index 0000000..af32ed0 --- /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 V1Instance Instance = new() + { + Metadata = new V1ObjectMeta() { NamespaceProperty = "seq", Name = "seq" }, + Spec = new V1InstanceSpec(), + }; + + 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/InstanceStatusRedactionTests.cs b/src/Alethic.Seq.Operator.Tests/InstanceStatusRedactionTests.cs new file mode 100644 index 0000000..029946f --- /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" }, + }, + }; + + V1InstanceController.RedactSecrets(info); + + Assert.AreEqual(V1InstanceController.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" }, + }, + }; + + V1InstanceController.RedactSecrets(info); + + 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); + } + + /// + /// 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 = "" }, + }, + }; + + V1InstanceController.RedactSecrets(info); + + Assert.AreEqual("", info.Auth.Oidc.ClientSecret); + } + + [TestMethod] + public void ToleratesNoAuth() + { + var info = new InstanceInfo(); + Assert.AreSame(info, V1InstanceController.RedactSecrets(info)); + Assert.IsNull(info.Auth); + } + + } + +} 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.Tests/PasswordGeneratorTests.cs b/src/Alethic.Seq.Operator.Tests/PasswordGeneratorTests.cs new file mode 100644 index 0000000..b84ff23 --- /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, V1InstanceController.GeneratePassword(length).Length); + } + + [TestMethod] + public void ContainsEveryCharacterClass() + { + for (var i = 0; i < 1000; i++) + { + 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); + 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(V1InstanceController.GeneratePassword(20)[0])); + + Assert.AreEqual(2, first.Count); + } + + [TestMethod] + public void DoesNotRepeat() + { + var passwords = Enumerable.Range(0, 1000).Select(_ => V1InstanceController.GeneratePassword(20)).ToHashSet(); + Assert.AreEqual(1000, passwords.Count); + } + + [TestMethod] + public void RejectsLengthTooShortForEveryClass() + { + Assert.ThrowsExactly(() => V1InstanceController.GeneratePassword(3)); + } + + } + +} 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/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/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/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 76% rename from src/Alethic.Seq.Operator/ApiKey/V1alpha1ApiKeyController.cs rename to src/Alethic.Seq.Operator/ApiKey/V1ApiKeyController.cs index b756dd8..49b50be 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,26 +66,71 @@ 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) 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(V1Instance instance, V1Namespace ns, IEnumerable permissions) + { + return permissions + .Distinct() + .Where(permission => instance.CheckPermission(ns, IsPermissionGrantedByDefault(permission), p => IsPermissionGranted(p.ApiKeys, permission)) == false) + .ToList(); } /// - 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); } @@ -96,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); } @@ -110,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 { @@ -133,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 @@ -144,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 { @@ -157,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) @@ -167,11 +212,16 @@ 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; } /// - 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(); @@ -180,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); @@ -189,22 +239,31 @@ 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; } /// - 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); } @@ -216,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); @@ -231,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(); @@ -242,7 +301,7 @@ async Task ApplySecretAsync(V1alpha1ApiKey entity, string? endpoint, string? tok 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) @@ -270,9 +329,10 @@ async Task ApplySecretAsync(V1alpha1ApiKey entity, string? endpoint, string? tok } // 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 @@ -393,7 +453,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/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/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 86% rename from src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs rename to src/Alethic.Seq.Operator/Instance/V1InstanceController.cs index f8a4378..4121498 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1InstanceController.cs +++ b/src/Alethic.Seq.Operator/Instance/V1InstanceController.cs @@ -33,37 +33,44 @@ 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"; + 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); } /// @@ -74,7 +81,7 @@ 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) { @@ -86,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); @@ -105,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) @@ -128,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; } @@ -141,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(); @@ -178,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); @@ -193,12 +201,12 @@ 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); // 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; @@ -213,7 +221,7 @@ async Task ReconcileDeploymentAsync(V1alpha1Instance instance, 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) @@ -240,7 +248,7 @@ async Task ReconcileDeploymentAsync(V1alpha1Instance instance, } // 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); @@ -258,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(); @@ -285,12 +293,12 @@ 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)) + if (deployment is null && operatorTokenApiKey is not null && operatorTokenApiKey.IsOwnedByEntity(instance)) { await Kube.DeleteAsync(operatorTokenApiKey, cancellationToken); operatorTokenApiKey = null; @@ -305,7 +313,7 @@ V1Secret ApplyDeploymentLoginSecret(V1alpha1Instance instance, InstanceDeploymen 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) @@ -327,12 +335,12 @@ 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); } // 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); @@ -351,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]; @@ -368,12 +376,12 @@ 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); // 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; @@ -388,7 +396,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep 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) @@ -412,7 +420,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance 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 (serviceAccount.IsOwnedBy(instance)) + if (serviceAccount.IsOwnedByEntity(instance)) { ApplyDeploymentResource(instance, deployment, "service-account", serviceAccount); serviceAccount = await Kube.UpdateAsync(serviceAccount, cancellationToken); @@ -430,12 +438,12 @@ 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); // 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; @@ -457,7 +465,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep 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) @@ -487,7 +495,7 @@ V1alpha1ApiKey ApplyDeploymentAdminApiKey(V1alpha1Instance instance, InstanceDep } // 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 @@ -507,10 +515,12 @@ 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(); - 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(); @@ -582,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); @@ -643,12 +655,12 @@ 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); // 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; @@ -663,7 +675,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDepl 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) @@ -690,7 +702,7 @@ V1StatefulSet ApplyDeploymentStatefulSet(V1alpha1Instance instance, InstanceDepl } // 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 @@ -707,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(); @@ -766,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(); @@ -787,14 +799,17 @@ T ApplyDeploymentResource(V1alpha1Instance instance, InstanceDeploymentSpec d 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; } /// - 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)); } /// @@ -927,7 +942,50 @@ async Task GetAuthSettingsAsync(SeqConnection api, InstanceConfAuthenticationSpe /// /// /// - async Task PutSettingValueAsync(V1alpha1Instance instance, SeqConnection api, SettingName name, T? value, CancellationToken cancellationToken) + /// + /// 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(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."); + + 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(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); @@ -944,7 +1002,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) { @@ -1066,7 +1124,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) { @@ -1119,9 +1177,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) @@ -1146,9 +1204,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) 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 f4e1cc1..00f07d7 100644 --- a/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs +++ b/src/Alethic.Seq.Operator/Instance/V1alpha1Instance.cs @@ -1,148 +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, - } - }; - - /// - /// 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 b1d0c2f..707cec2 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; } /// @@ -136,11 +152,11 @@ public LookupService(IKubernetesClient kube, IMemoryCache cache) /// /// /// - 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; }); @@ -153,7 +169,7 @@ public LookupService(IKubernetesClient kube, IMemoryCache cache) /// /// /// - 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/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; + } + + } + +} 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 69% rename from src/Alethic.Seq.Operator/V1alpha1InstanceEntityController.cs rename to src/Alethic.Seq.Operator/V1InstanceEntityController.cs index 91c1072..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,32 @@ 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 + /// behind as a duplicate. Failure to remove it is logged and swallowed, so the original error is what surfaces. + /// + /// + /// + /// + /// + /// + /// + /// + 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); + + 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 @@ -240,30 +278,44 @@ 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) { + // 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 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; } - - } - -}