diff --git a/README.md b/README.md index 2db9246..e125fb3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Reflector -Reflector is a Kubernetes addon designed to monitor changes to resources (secrets and configmaps) and reflect changes to mirror resources in the same or other namespaces. +Reflector is a Kubernetes addon designed to monitor changes to resources (secrets, configmaps, service accounts, roles and rolebindings) and reflect changes to mirror resources in the same or other namespaces. [![Pipeline](https://github.com/emberstack/kubernetes-reflector/actions/workflows/pipeline.yaml/badge.svg)](https://github.com/emberstack/kubernetes-reflector/actions/workflows/pipeline.yaml) [![Release](https://img.shields.io/github/release/emberstack/kubernetes-reflector.svg?style=flat-square)](https://github.com/emberstack/kubernetes-reflector/releases/latest) @@ -70,7 +70,7 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle ## Usage -### 1. Annotate the source `secret` or `configmap` +### 1. Annotate the source resource - Add `reflector.v1.k8s.emberstack.com/reflection-allowed: "true"` to the resource annotations to permit reflection to mirrors. - Add `reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: ""` to the resource annotations to permit reflection from only the list of comma separated namespaces or regular expressions. Note: If this annotation is omitted or is empty, all namespaces are allowed. @@ -110,7 +110,60 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle ... ``` -### 2. Annotate the mirror secret or configmap + Example source service account: + ```yaml + piVersion: v1 + kind: ServiceAccount + metadata: + name: source-service-account + namespace: default + annotations: + reflector.v1.k8s.emberstack.com/reflection-allowed: 'true' + reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" + ... + ``` + Example source role: + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: source-role + namespace: default + annotations: + reflector.v1.k8s.emberstack.com/reflection-allowed: "true" + reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" + rules: + - verbs: + - '*' + apiGroups: + - '*' + resources: + - '*' + ... + ``` + Example source rolebindings: + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: source-role-binding + namespace: default + annotations: + reflector.v1.k8s.emberstack.com/reflection-allowed: "true" + reflector.v1.k8s.emberstack.com/reflection-allowed-namespaces: "namespace-1,namespace-2,namespace-[0-9]*" + ... + subjects: + - kind: ServiceAccount + name: source-service-account + namespace: default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: source-role + ``` + + +### 2. Annotate the mirror resource - Add `reflector.v1.k8s.emberstack.com/reflects: "/"` to the mirror object. The value of the annotation is the full name of the source object in `namespace/name` format. @@ -140,16 +193,54 @@ $ kubectl -n kube-system apply -f https://github.com/emberstack/kubernetes-refle ... ``` + Example mirror service account: + ```yaml + apiVersion: v1 + kind: ServiceAccount + metadata: + name: mirror-service-account + annotations: + reflector.v1.k8s.emberstack.com/reflects: "default/source-service-account" + data: + ... + ``` + + Example mirror role: + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: mirror-role + annotations: + reflector.v1.k8s.emberstack.com/reflects: "default/source-role" + data: + ... + ``` + + Example mirror rolebinding: + ```yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: mirror-role-binding + annotations: + reflector.v1.k8s.emberstack.com/reflects: "default/source-role-binding" + data: + ... + ``` + ### 3. Done! Reflector will monitor any changes done to the source objects and copy the following fields: - `data` for secrets - `data` and `binaryData` for configmaps + - nothing for service accounts + - `rules` for roles + - `subjects` only for rolebindings, as `roleRef` is immutable Reflector keeps track of what was copied by annotating mirrors with the source object version. - - - - - ## `cert-manager` support > Since version 1.5 of cert-manager you can annotate secrets created from certificates for mirroring using `secretTemplate` (see https://cert-manager.io/docs/usage/certificate/). diff --git a/src/ES.Kubernetes.Reflector/Core/Mirroring/ResourceMirror.cs b/src/ES.Kubernetes.Reflector/Core/Mirroring/ResourceMirror.cs index 05e7c8f..14876b4 100644 --- a/src/ES.Kubernetes.Reflector/Core/Mirroring/ResourceMirror.cs +++ b/src/ES.Kubernetes.Reflector/Core/Mirroring/ResourceMirror.cs @@ -1,4 +1,4 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using System.Net; using ES.Kubernetes.Reflector.Core.Extensions; using ES.Kubernetes.Reflector.Core.Json; @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.JsonPatch; using Microsoft.AspNetCore.JsonPatch.Operations; using Newtonsoft.Json; +using CloudNimble.EasyAF.NewtonsoftJson.Compatibility; namespace ES.Kubernetes.Reflector.Core.Mirroring; @@ -478,7 +479,7 @@ private async Task ResourceReflect(KubeRef sourceId, KubeRef targetId, TResource await OnResourceConfigurePatch(source, patchDoc); - var patch = JsonConvert.SerializeObject(patchDoc, Formatting.Indented); + var patch = JsonConvert.SerializeObject(patchDoc, Formatting.Indented, new JsonSerializerSettings { ContractResolver = new SystemTextJsonContractResolver() }); await OnResourceApplyPatch(new V1Patch(patch, V1Patch.PatchType.JsonPatch), targetId); Logger.LogInformation("Patched {id} as a reflection of {sourceId}", targetId, sourceId); } diff --git a/src/ES.Kubernetes.Reflector/Core/RoleBindingMirror.cs b/src/ES.Kubernetes.Reflector/Core/RoleBindingMirror.cs new file mode 100644 index 0000000..6fe1d13 --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/RoleBindingMirror.cs @@ -0,0 +1,61 @@ +using ES.Kubernetes.Reflector.Core.Mirroring; +using ES.Kubernetes.Reflector.Core.Resources; +using k8s; +using k8s.Models; +using Microsoft.AspNetCore.JsonPatch; +using System.Text.Json; +using System.Diagnostics; + +namespace ES.Kubernetes.Reflector.Core; + +public class RoleBindingMirror : ResourceMirror +{ + public RoleBindingMirror(ILogger logger, IKubernetes client) : base(logger, client) + { + } + + protected override async Task OnResourceWithNameList(string itemRefName) + { + return (await Client.RbacAuthorizationV1.ListRoleBindingForAllNamespacesAsync(fieldSelector: $"metadata.name={itemRefName}")).Items + .ToArray(); + } + + protected override Task OnResourceApplyPatch(V1Patch patch, KubeRef refId) + { + return Client.RbacAuthorizationV1.PatchNamespacedRoleBindingWithHttpMessagesAsync(patch, refId.Name, refId.Namespace); + } + + protected override Task OnResourceConfigurePatch(V1RoleBinding source, JsonPatchDocument patchDoc) + { + // Roleref is immutable by design, so we only patch the Subjects list + patchDoc.Replace(e => e.Subjects, source.Subjects); + return Task.CompletedTask; + } + + protected override Task OnResourceCreate(V1RoleBinding item, string ns) + { + item.Metadata.ResourceVersion = null; + return Client.RbacAuthorizationV1.CreateNamespacedRoleBindingAsync(item, ns); + } + + protected override Task OnResourceClone(V1RoleBinding sourceResource) + { + return Task.FromResult(new V1RoleBinding + { + ApiVersion = sourceResource.ApiVersion, + Kind = sourceResource.Kind, + Subjects = sourceResource.Subjects, + RoleRef = sourceResource.RoleRef + }); + } + + protected override Task OnResourceDelete(KubeRef resourceId) + { + return Client.RbacAuthorizationV1.DeleteNamespacedRoleBindingAsync(resourceId.Name, resourceId.Namespace); + } + + protected override Task OnResourceGet(KubeRef refId) + { + return Client.RbacAuthorizationV1.ReadNamespacedRoleBindingAsync(refId.Name, refId.Namespace); + } +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/Core/RoleBindingWatcher.cs b/src/ES.Kubernetes.Reflector/Core/RoleBindingWatcher.cs new file mode 100644 index 0000000..1c28c96 --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/RoleBindingWatcher.cs @@ -0,0 +1,24 @@ +using ES.Kubernetes.Reflector.Core.Configuration; +using ES.Kubernetes.Reflector.Core.Watchers; +using k8s; +using k8s.Autorest; +using k8s.Models; +using MediatR; +using Microsoft.Extensions.Options; + +namespace ES.Kubernetes.Reflector.Core; + +public class RoleBindingWatcher : WatcherBackgroundService +{ + public RoleBindingWatcher(ILogger logger, IMediator mediator, IKubernetes client, + IOptionsMonitor options) : + base(logger, mediator, client, options) + { + } + + protected override Task> OnGetWatcher(CancellationToken cancellationToken) + { + return Client.RbacAuthorizationV1.ListRoleBindingForAllNamespacesWithHttpMessagesAsync(watch: true, timeoutSeconds: WatcherTimeout, + cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/Core/RoleMirror.cs b/src/ES.Kubernetes.Reflector/Core/RoleMirror.cs new file mode 100644 index 0000000..b744f9d --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/RoleMirror.cs @@ -0,0 +1,60 @@ +using ES.Kubernetes.Reflector.Core.Mirroring; +using ES.Kubernetes.Reflector.Core.Resources; +using k8s; +using k8s.Models; +using Microsoft.AspNetCore.JsonPatch; +using System.Text.Json; + +namespace ES.Kubernetes.Reflector.Core; + +public class RoleMirror : ResourceMirror +{ + public RoleMirror(ILogger logger, IKubernetes client) : base(logger, client) + { + } + + protected override async Task OnResourceWithNameList(string itemRefName) + { + return (await Client.RbacAuthorizationV1.ListRoleForAllNamespacesAsync(fieldSelector: $"metadata.name={itemRefName}")).Items + .ToArray(); + } + + protected override Task OnResourceApplyPatch(V1Patch patch, KubeRef refId) + { + return Client.RbacAuthorizationV1.PatchNamespacedRoleWithHttpMessagesAsync(patch, refId.Name, refId.Namespace); + } + + protected override Task OnResourceConfigurePatch(V1Role source, JsonPatchDocument patchDoc) + { + // Replace with new List of Rules + patchDoc.Replace(e => e.Rules, source.Rules); + return Task.CompletedTask; + } + + protected override Task OnResourceCreate(V1Role item, string ns) + { + item.Metadata.ResourceVersion = null; + return Client.RbacAuthorizationV1.CreateNamespacedRoleAsync(item, ns); + } + + protected override Task OnResourceClone(V1Role sourceResource) + { + return Task.FromResult(new V1Role + { + ApiVersion = sourceResource.ApiVersion, + Kind = sourceResource.Kind, + Rules = sourceResource.Rules + }); + } + + protected override Task OnResourceDelete(KubeRef resourceId) + { + return Client.RbacAuthorizationV1.DeleteNamespacedRoleAsync(resourceId.Name, resourceId.Namespace); + } + + protected override Task OnResourceGet(KubeRef refId) + { + return Client.RbacAuthorizationV1.ReadNamespacedRoleAsync(refId.Name, refId.Namespace); + } + +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/Core/RoleWatcher.cs b/src/ES.Kubernetes.Reflector/Core/RoleWatcher.cs new file mode 100644 index 0000000..f7829f4 --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/RoleWatcher.cs @@ -0,0 +1,24 @@ +using ES.Kubernetes.Reflector.Core.Configuration; +using ES.Kubernetes.Reflector.Core.Watchers; +using k8s; +using k8s.Autorest; +using k8s.Models; +using MediatR; +using Microsoft.Extensions.Options; + +namespace ES.Kubernetes.Reflector.Core; + +public class RoleWatcher : WatcherBackgroundService +{ + public RoleWatcher(ILogger logger, IMediator mediator, IKubernetes client, + IOptionsMonitor options) : + base(logger, mediator, client, options) + { + } + + protected override Task> OnGetWatcher(CancellationToken cancellationToken) + { + return Client.RbacAuthorizationV1.ListRoleForAllNamespacesWithHttpMessagesAsync(watch: true, timeoutSeconds: WatcherTimeout, + cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/Core/ServiceAccountMirror.cs b/src/ES.Kubernetes.Reflector/Core/ServiceAccountMirror.cs new file mode 100644 index 0000000..47ceefb --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/ServiceAccountMirror.cs @@ -0,0 +1,56 @@ +using ES.Kubernetes.Reflector.Core.Mirroring; +using ES.Kubernetes.Reflector.Core.Resources; +using k8s; +using k8s.Models; +using Microsoft.AspNetCore.JsonPatch; + +namespace ES.Kubernetes.Reflector.Core; + +public class ServiceAccountMirror : ResourceMirror +{ + public ServiceAccountMirror(ILogger logger, IKubernetes client) : base(logger, client) + { + } + + protected override async Task OnResourceWithNameList(string itemRefName) + { + return (await Client.CoreV1.ListServiceAccountForAllNamespacesAsync(fieldSelector: $"metadata.name={itemRefName}")).Items + .ToArray(); + } + + protected override Task OnResourceApplyPatch(V1Patch patch, KubeRef refId) + { + return Client.CoreV1.PatchNamespacedServiceAccountWithHttpMessagesAsync(patch, refId.Name, refId.Namespace); + } + + protected override Task OnResourceConfigurePatch(V1ServiceAccount source, JsonPatchDocument patchDoc) + { + // Just update annotations. + return Task.CompletedTask; + } + + protected override Task OnResourceCreate(V1ServiceAccount item, string ns) + { + item.Metadata.ResourceVersion = null; + return Client.CoreV1.CreateNamespacedServiceAccountAsync(item, ns); + } + + protected override Task OnResourceClone(V1ServiceAccount sourceResource) + { + return Task.FromResult(new V1ServiceAccount + { + ApiVersion = sourceResource.ApiVersion, + Kind = sourceResource.Kind + }); + } + + protected override Task OnResourceDelete(KubeRef resourceId) + { + return Client.CoreV1.DeleteNamespacedServiceAccountAsync(resourceId.Name, resourceId.Namespace); + } + + protected override Task OnResourceGet(KubeRef refId) + { + return Client.CoreV1.ReadNamespacedServiceAccountAsync(refId.Name, refId.Namespace); + } +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/Core/ServiceAccountWatcher.cs b/src/ES.Kubernetes.Reflector/Core/ServiceAccountWatcher.cs new file mode 100644 index 0000000..bc653cc --- /dev/null +++ b/src/ES.Kubernetes.Reflector/Core/ServiceAccountWatcher.cs @@ -0,0 +1,24 @@ +using ES.Kubernetes.Reflector.Core.Configuration; +using ES.Kubernetes.Reflector.Core.Watchers; +using k8s; +using k8s.Autorest; +using k8s.Models; +using MediatR; +using Microsoft.Extensions.Options; + +namespace ES.Kubernetes.Reflector.Core; + +public class ServiceAccountWatcher : WatcherBackgroundService +{ + public ServiceAccountWatcher(ILogger logger, IMediator mediator, IKubernetes client, + IOptionsMonitor options) : + base(logger, mediator, client, options) + { + } + + protected override Task> OnGetWatcher(CancellationToken cancellationToken) + { + return Client.CoreV1.ListServiceAccountForAllNamespacesWithHttpMessagesAsync(watch: true, timeoutSeconds: WatcherTimeout, + cancellationToken: cancellationToken); + } +} \ No newline at end of file diff --git a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj index e4146be..7032741 100644 --- a/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj +++ b/src/ES.Kubernetes.Reflector/ES.Kubernetes.Reflector.csproj @@ -10,6 +10,7 @@ + diff --git a/src/ES.Kubernetes.Reflector/Program.cs b/src/ES.Kubernetes.Reflector/Program.cs index 5cae923..9935a09 100644 --- a/src/ES.Kubernetes.Reflector/Program.cs +++ b/src/ES.Kubernetes.Reflector/Program.cs @@ -75,6 +75,15 @@ container.RegisterType().AsImplementedInterfaces().SingleInstance(); container.RegisterType().AsImplementedInterfaces().SingleInstance(); + + container.RegisterType().AsImplementedInterfaces().SingleInstance(); + container.RegisterType().AsImplementedInterfaces().SingleInstance(); + + container.RegisterType().AsImplementedInterfaces().SingleInstance(); + container.RegisterType().AsImplementedInterfaces().SingleInstance(); + + container.RegisterType().AsImplementedInterfaces().SingleInstance(); + container.RegisterType().AsImplementedInterfaces().SingleInstance(); }); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(25080); }); diff --git a/src/helm/reflector/templates/NOTES.txt b/src/helm/reflector/templates/NOTES.txt index 5158650..c4bac8e 100644 --- a/src/helm/reflector/templates/NOTES.txt +++ b/src/helm/reflector/templates/NOTES.txt @@ -1 +1 @@ -Reflector can now be used to perform automatic copy actions on secrets and configmaps. \ No newline at end of file +Reflector can now be used to perform automatic copy actions on secrets, configmaps, service accounts, roles and rolebindings. \ No newline at end of file diff --git a/src/helm/reflector/templates/clusterRole.yaml b/src/helm/reflector/templates/clusterRole.yaml index 068bfa1..bf813c6 100644 --- a/src/helm/reflector/templates/clusterRole.yaml +++ b/src/helm/reflector/templates/clusterRole.yaml @@ -8,9 +8,12 @@ metadata: {{- include "reflector.labels" . | nindent 4 }} rules: - apiGroups: [""] - resources: ["configmaps", "secrets"] + resources: ["configmaps", "secrets", "serviceaccounts"] verbs: ["*"] - apiGroups: [""] resources: ["namespaces"] verbs: ["watch", "list"] + - apiGroups: ["rbac.authorization.k8s.io"] + resources: [ "roles", "rolebindings"] + verbs: ["*"] {{- end }}