diff --git a/chart/charts/postgres-0.14.4.tgz b/chart/charts/postgres-0.14.4.tgz new file mode 100644 index 0000000..e611176 Binary files /dev/null and b/chart/charts/postgres-0.14.4.tgz differ diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl index 36c6d72..8ba114d 100644 --- a/chart/templates/_helpers.tpl +++ b/chart/templates/_helpers.tpl @@ -60,3 +60,12 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +True when the pvc-group plugin is enabled in values. Used to gate the +PVC RBAC role/rolebinding so clusters without the plugin don't get the +extra permissions. +*/}} +{{- define "ldap-sync.pvcGroupEnabled" -}} +{{- ((((.Values.config).plugins).pvcGroup).enabled) | ternary "true" "" -}} +{{- end }} diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 8447cc1..cb93c71 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -43,3 +43,17 @@ data: database: enabled: false {{- end }} + plugins: + enabled: + {{- with .Values.config.plugins.pvcGroup }} + {{- if .enabled }} + - name: pvc-group + enabled: true + options: + namespace: {{ $.Release.Namespace | quote }} + storageClass: {{ .storageClass | quote }} + size: {{ .size | quote }} + accessModes: + {{- toYaml .accessModes | nindent 14 }} + {{- end }} + {{- end }} diff --git a/chart/templates/rbac.yaml b/chart/templates/rbac.yaml new file mode 100644 index 0000000..d6e73f9 --- /dev/null +++ b/chart/templates/rbac.yaml @@ -0,0 +1,27 @@ +{{- if eq (include "ldap-sync.pvcGroupEnabled" .) "true" }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: {{ include "ldap-sync.fullname" . }}-pvc-group + labels: + {{- include "ldap-sync.labels" . | nindent 4 }} +rules: + - apiGroups: [""] + resources: ["persistentvolumeclaims"] + verbs: ["get", "list", "create"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: {{ include "ldap-sync.fullname" . }}-pvc-group + labels: + {{- include "ldap-sync.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: {{ include "ldap-sync.fullname" . }}-pvc-group +subjects: + - kind: ServiceAccount + name: {{ include "ldap-sync.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 81df6b3..12d0375 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -94,6 +94,16 @@ config: bindDN: "cn=admin,dc=example,dc=org" bindPassword: "" baseDN: "dc=example,dc=org" + plugins: + # Creates a PVC in the release namespace for every synced group + # entry. When enabled, the chart also installs a Role/RoleBinding + # granting the service account get/list/create on PVCs. + pvcGroup: + enabled: true + storageClass: "" + size: 10Gi + accessModes: + - ReadWriteMany # PostgreSQL configuration postgres: diff --git a/go.mod b/go.mod index 60b2ebc..4a9b5f8 100644 --- a/go.mod +++ b/go.mod @@ -9,31 +9,60 @@ require ( github.com/swaggo/echo-swagger v1.4.1 github.com/swaggo/swag v1.16.4 gopkg.in/yaml.v2 v2.4.0 + k8s.io/api v0.31.0 + k8s.io/apimachinery v0.31.0 + k8s.io/client-go v0.31.0 ) require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/KyleBanks/depth v1.2.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/ghodss/yaml v1.0.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-openapi/jsonpointer v0.21.1 // indirect github.com/go-openapi/jsonreference v0.21.0 // indirect github.com/go-openapi/spec v0.21.0 // indirect github.com/go-openapi/swag v0.23.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/gofuzz v1.2.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/labstack/gommon v0.4.2 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/spf13/pflag v1.0.5 // indirect github.com/swaggo/files/v2 v2.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/crypto v0.36.0 // indirect golang.org/x/net v0.37.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sys v0.31.0 // indirect + golang.org/x/term v0.30.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/time v0.8.0 // indirect golang.org/x/tools v0.31.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 21c1198..d4bb784 100644 --- a/go.sum +++ b/go.sum @@ -5,14 +5,21 @@ github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6Xge github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= +github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= @@ -21,7 +28,22 @@ github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9Z github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af h1:kmjWCqn2qkEml422C2Rrd27c3VGxi6a/6HNq8QmHRKM= +github.com/google/pprof v0.0.0-20240525223248-4bfdf5a9a2af/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= @@ -29,6 +51,8 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -43,6 +67,10 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -60,13 +88,28 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA= +github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -83,8 +126,14 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= @@ -93,6 +142,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -100,8 +151,11 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -113,7 +167,11 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= @@ -123,6 +181,8 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -146,6 +206,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -161,6 +223,8 @@ golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= @@ -168,12 +232,38 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= +k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= +k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= +k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= +k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= +k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A= +k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= diff --git a/main.go b/main.go index 5054e34..6d25489 100644 --- a/main.go +++ b/main.go @@ -54,6 +54,26 @@ type HookRetryConfig struct { MaxDelayMs int `yaml:"max_delay_ms"` } +// PluginRetryConfig holds retry configuration for plugin Apply calls. +type PluginRetryConfig struct { + MaxAttempts int `yaml:"max_attempts"` + InitialDelayMs int `yaml:"initial_delay_ms"` + MaxDelayMs int `yaml:"max_delay_ms"` +} + +// PluginConfig describes one configured plugin instance. +type PluginConfig struct { + Name string `yaml:"name"` + Enabled bool `yaml:"enabled"` + Options map[string]interface{} `yaml:"options"` +} + +// PluginsConfig groups all plugin configuration. +type PluginsConfig struct { + Retry PluginRetryConfig `yaml:"retry"` + Enabled []PluginConfig `yaml:"enabled"` +} + // Config holds the configuration for both source and target LDAP servers. type Config struct { Source LDAPConfig `yaml:"source"` @@ -61,6 +81,7 @@ type Config struct { Hooks []string `yaml:"hooks"` Database DatabaseConfig `yaml:"database"` HookRetry HookRetryConfig `yaml:"hook_retry"` + Plugins PluginsConfig `yaml:"plugins"` } // SearchSpec represents a running search instance. @@ -146,7 +167,7 @@ var db *sql.DB // ldapStore is the function used to write a transformed entry to the destination // LDAP. It is a variable so tests can replace it with a mock without a live server. -var ldapStore func(*TransformedEntry) error +var ldapStore func(*TransformedEntry) (SyncOp, error) // handleEntryWindowHook is called in handleEntry between the first lock release // and the second lock acquisition — the exact window where the two-phase race @@ -155,9 +176,10 @@ var ldapStore func(*TransformedEntry) error var handleEntryWindowHook func() type pendingEntry struct { - entry *TransformedEntry - deps map[string]struct{} - rawDeps []string + entry *TransformedEntry + deps map[string]struct{} + rawDeps []string + searchID string } type dependencyState struct { @@ -621,7 +643,7 @@ func collectMissingBindings(entry *TransformedEntry, deps []string, bindings map return keys } -func (d *dependencyState) handleEntry(entry *TransformedEntry, deps []string) { +func (d *dependencyState) handleEntry(entry *TransformedEntry, deps []string, searchID string) { parentKey := normalizeDN(entry.DN) if parentKey == "" { logger.Error("Transformed entry has empty DN; skipping dependency processing") @@ -637,6 +659,11 @@ func (d *dependencyState) handleEntry(entry *TransformedEntry, deps []string) { if len(existing.rawDeps) > 0 { rawDeps = append(rawDeps, existing.rawDeps...) } + // Inherit the searchID from the earlier pending entry if the caller + // did not supply one, so plugin events keep a stable origin. + if searchID == "" { + searchID = existing.searchID + } for depKey := range existing.deps { parents := d.reverse[depKey] if parents != nil { @@ -698,11 +725,12 @@ func (d *dependencyState) handleEntry(entry *TransformedEntry, deps []string) { if len(missing) == 0 && !entryMissing && !depsMissing { d.mu.Unlock() - if err := ldapStore(resolvedEntry); err != nil { + op, err := ldapStore(resolvedEntry) + if err != nil { logger.Error("Error storing entry in destination LDAP", "DN", resolvedEntry.DN, "Err", err) return } - d.markSyncedAndRelease(resolvedEntry.DN) + d.markSyncedAndRelease(resolvedEntry.DN, searchID, resolvedEntry.Content, op) return } @@ -730,9 +758,10 @@ func (d *dependencyState) handleEntry(entry *TransformedEntry, deps []string) { } d.pending[parentKey] = &pendingEntry{ - entry: entry, - deps: missing, - rawDeps: rawDeps, + entry: entry, + deps: missing, + rawDeps: rawDeps, + searchID: searchID, } for depKey := range missing { parents := d.reverse[depKey] @@ -802,11 +831,11 @@ func (d *dependencyState) reprocessPending() { continue } logger.Debug("Reprocessing pending entry", "DN", pending.entry.DN, "RawDeps", len(pending.rawDeps)) - d.handleEntry(pending.entry, pending.rawDeps) + d.handleEntry(pending.entry, pending.rawDeps, pending.searchID) } } -func (d *dependencyState) markSyncedAndRelease(dn string) { +func (d *dependencyState) markSyncedAndRelease(dn string, searchID string, content map[string]interface{}, op SyncOp) { dnKey := normalizeDN(dn) if dnKey == "" { return @@ -889,17 +918,32 @@ func (d *dependencyState) markSyncedAndRelease(dn string) { "Deferred entry still missing bindings on release", "DN", pending.entry.DN, ) - d.handleEntry(pending.entry, pending.rawDeps) + d.handleEntry(pending.entry, pending.rawDeps, pending.searchID) continue } - if err := ldapStore(resolvedEntry); err != nil { + pendingOp, err := ldapStore(resolvedEntry) + if err != nil { logger.Error("Error storing deferred entry in destination LDAP", "DN", resolvedEntry.DN, "Err", err) continue } logger.Info("Storing deferred entry in destination LDAP", "DN", resolvedEntry.DN) - d.markSyncedAndRelease(resolvedEntry.DN) + d.markSyncedAndRelease(resolvedEntry.DN, pending.searchID, resolvedEntry.Content, pendingOp) } } + + // Plugin dispatch fires only after the entry is durably in target LDAP and + // outside the dependencyState mutex. An empty op signals "no actual write + // happened" (e.g. duplicate markSynced for an already-synced DN), in which + // case there is nothing to announce. + if op != "" { + dispatchSyncEvent(SyncEvent{ + SearchID: searchID, + DN: dn, + Content: content, + Op: op, + Timestamp: time.Now(), + }) + } } // initLogger initializes the logger using log/slog. @@ -992,7 +1036,7 @@ func performLDAPSearch(l *ldap.Conn, baseDN, filter string) (*ldap.SearchResult, return l.Search(searchRequest) } -func storeDestinationLDAP(entry *TransformedEntry) error { +func storeDestinationLDAP(entry *TransformedEntry) (SyncOp, error) { lock := getDNLock(entry.DN) lock.Lock() defer lock.Unlock() @@ -1000,13 +1044,13 @@ func storeDestinationLDAP(entry *TransformedEntry) error { // Connect to destination LDAP. l, err := ldap.DialURL(config.Target.URL) if err != nil { - return err + return "", err } defer l.Close() // Bind with destination credentials. if err = l.Bind(config.Target.BindDN, config.Target.BindPassword); err != nil { - return err + return "", err } // Check if the entry exists. @@ -1034,7 +1078,7 @@ func storeDestinationLDAP(entry *TransformedEntry) error { // Treat it as if no entry was found. sr = &ldap.SearchResult{Entries: []*ldap.Entry{}} } else { - return err + return "", err } } @@ -1069,9 +1113,10 @@ func storeDestinationLDAP(entry *TransformedEntry) error { addReq.Attribute("objectClass", []string{"top", "inetOrgPerson"}) } if err = l.Add(addReq); err != nil { - return err + return "", err } logger.Info("Added entry to destination LDAP", "DN", entry.DN) + return SyncOpCreated, nil } else { entryData := sr.Entries[0] for attr, values := range attributes { @@ -1095,11 +1140,11 @@ func storeDestinationLDAP(entry *TransformedEntry) error { modReq.Replace(attr, values) } if err = l.Modify(modReq); err != nil { - return err + return "", err } logger.Info("Modified entry in destination LDAP", "DN", entry.DN) + return SyncOpUpdated, nil } - return nil } // ldapSearchAndSync performs the LDAP search on the source server and synchronizes the results. @@ -1157,7 +1202,7 @@ func ldapSearchAndSync(id, filter, baseDN string, refresh int, oneshot bool, sto } // processHookResponse is a stub for processing the hook response. -func processHookResponse(hookResp HookResponse) { +func processHookResponse(hookResp HookResponse, sourceSearchID string) { // Log the parsed hook response values. logger.Debug("Processing Hook response", "Transformed", hookResp.Transformed, "Derived", hookResp.Derived, "Reset", hookResp.Reset) @@ -1171,7 +1216,7 @@ func processHookResponse(hookResp HookResponse) { for i := range hookResp.Transformed { transformed := hookResp.Transformed[i] logger.Debug("Processing transformed hook response for DN", "DN", transformed.DN) - dependencyTracker.handleEntry(&transformed, hookResp.Dependencies) + dependencyTracker.handleEntry(&transformed, hookResp.Dependencies, sourceSearchID) } } else { logger.Info("No transformed data in hook response") @@ -1296,7 +1341,7 @@ func postToHookWithRetry(hookURL string, payload []byte) (*http.Response, error) } // sendHooks posts the LDAP result to each URL specified in config.Hooks. -func sendHooks(result LDAPResult) { +func sendHooks(result LDAPResult, sourceSearchID string) { payload, err := json.Marshal(result) if err != nil { logger.Error("Error marshalling hook payload for DN", "DN", result.DN, "Err", err) @@ -1324,7 +1369,7 @@ func sendHooks(result LDAPResult) { } for _, hookResp := range hookResps { - processHookResponse(hookResp) + processHookResponse(hookResp, sourceSearchID) } }(url) } @@ -1383,7 +1428,7 @@ func processLDAPEntry(id string, entry *ldap.Entry, oneshot bool) { } if shouldSend { - sendHooks(newResult) + sendHooks(newResult, id) } } @@ -1745,6 +1790,8 @@ func main() { os.Exit(1) } + initPluginRegistry(config.Plugins) + // Initialize database if enabled in config if config.Database.Enabled { if err := initDB(config.Database); err != nil { diff --git a/main_test.go b/main_test.go index 8ebcd5d..b00ef2e 100644 --- a/main_test.go +++ b/main_test.go @@ -48,16 +48,27 @@ func resetState(t *testing.T) { dependencyTracker = newDependencyState() dnLocks = sync.Map{} db = nil + + pluginRegistry = nil + dispatchSyncEvent = func(event SyncEvent) { + if pluginRegistry == nil { + return + } + pluginRegistry.Dispatch(event) + } } // mockStore captures calls to ldapStore and records the entries written. +// The reported SyncOp is Created the first time a DN is seen and Updated +// thereafter, mirroring storeDestinationLDAP's Add-vs-Modify branch. type mockStore struct { mu sync.Mutex written []*TransformedEntry + seen map[string]struct{} err error // returned on every call if set } -func (s *mockStore) store(e *TransformedEntry) error { +func (s *mockStore) store(e *TransformedEntry) (SyncOp, error) { s.mu.Lock() defer s.mu.Unlock() // Deep-copy content so concurrent mutations don't corrupt the snapshot. @@ -66,7 +77,18 @@ func (s *mockStore) store(e *TransformedEntry) error { copied[k] = v } s.written = append(s.written, &TransformedEntry{DN: e.DN, Content: copied}) - return s.err + if s.err != nil { + return "", s.err + } + if s.seen == nil { + s.seen = make(map[string]struct{}) + } + key := normalizeDN(e.DN) + if _, ok := s.seen[key]; ok { + return SyncOpUpdated, nil + } + s.seen[key] = struct{}{} + return SyncOpCreated, nil } func (s *mockStore) entries() []*TransformedEntry { @@ -395,7 +417,7 @@ func TestHandleEntry_NoDeps(t *testing.T) { DN: "uid=alice,ou=users,dc=example,dc=org", Content: map[string]interface{}{"groups": []interface{}{"users"}}, } - ds.handleEntry(entry, nil) + ds.handleEntry(entry, nil, "test-search") written := ms.entries() if len(written) != 1 { @@ -420,7 +442,7 @@ func TestHandleEntry_PendingDeps(t *testing.T) { DN: "uid=alice,ou=users,dc=example,dc=org", Content: map[string]interface{}{"groups": []interface{}{"users"}}, } - ds.handleEntry(entry, []string{dep}) + ds.handleEntry(entry, []string{dep}, "test-search") // Not written yet — dep not synced if len(ms.entries()) != 0 { @@ -428,7 +450,7 @@ func TestHandleEntry_PendingDeps(t *testing.T) { } // Syncing the dep should release alice - ds.markSyncedAndRelease(dep) + ds.markSyncedAndRelease(dep, "", nil, "") written := ms.entries() if len(written) != 1 { @@ -451,7 +473,7 @@ func TestHandleEntry_SelfDepSkipped(t *testing.T) { Content: map[string]interface{}{"groups": []interface{}{"users"}}, } // dep is same as the entry DN — should be ignored - ds.handleEntry(entry, []string{dn}) + ds.handleEntry(entry, []string{dn}, "test-search") if len(ms.entries()) != 1 { t.Fatalf("self-dep should be skipped; expected 1 write, got %d", len(ms.entries())) @@ -472,7 +494,7 @@ func TestHandleEntry_MissingBindings(t *testing.T) { DN: "uid=$pidUidMap.p99,ou=users,dc=example,dc=org", Content: map[string]interface{}{"groups": []interface{}{"users"}}, } - dependencyTracker.handleEntry(entry, nil) + dependencyTracker.handleEntry(entry, nil, "test-search") // Binding not set → should not be written yet if len(ms.entries()) != 0 { @@ -515,8 +537,8 @@ func TestHandleEntry_ConcurrentSameDN_MergesGroups(t *testing.T) { var wg sync.WaitGroup wg.Add(2) - go func() { defer wg.Done(); ds.handleEntry(eaglePatch, []string{sharedDep}) }() - go func() { defer wg.Done(); ds.handleEntry(falconPatch, []string{sharedDep}) }() + go func() { defer wg.Done(); ds.handleEntry(eaglePatch, []string{sharedDep}, "test-search") }() + go func() { defer wg.Done(); ds.handleEntry(falconPatch, []string{sharedDep}, "test-search") }() wg.Wait() // Both patches pending — nothing written yet @@ -525,7 +547,7 @@ func TestHandleEntry_ConcurrentSameDN_MergesGroups(t *testing.T) { } // Release the shared dep → pending entry for Alice fires - ds.markSyncedAndRelease(sharedDep) + ds.markSyncedAndRelease(sharedDep, "", nil, "") written := ms.entries() if len(written) != 1 { @@ -608,8 +630,8 @@ func TestHandleEntry_DeterministicRace(t *testing.T) { var wg sync.WaitGroup wg.Add(2) - go func() { defer wg.Done(); ds.handleEntry(eaglePatch, []string{sharedDep}) }() - go func() { defer wg.Done(); ds.handleEntry(falconPatch, []string{sharedDep}) }() + go func() { defer wg.Done(); ds.handleEntry(eaglePatch, []string{sharedDep}, "test-search") }() + go func() { defer wg.Done(); ds.handleEntry(falconPatch, []string{sharedDep}, "test-search") }() // Wait until both goroutines are stalled inside the window (up to 5 s). select { @@ -628,7 +650,7 @@ func TestHandleEntry_DeterministicRace(t *testing.T) { t.Fatalf("expected 0 writes before dep synced; got %d", n) } - ds.markSyncedAndRelease(sharedDep) + ds.markSyncedAndRelease(sharedDep, "", nil, "") written := ms.entries() if len(written) != 1 { @@ -684,10 +706,10 @@ func TestMarkSyncedAndRelease_Chain(t *testing.T) { entryB := &TransformedEntry{DN: "uid=b,ou=users,dc=example,dc=org", Content: map[string]interface{}{}} entryC := &TransformedEntry{DN: "uid=c,ou=users,dc=example,dc=org", Content: map[string]interface{}{}} - ds.handleEntry(entryC, []string{entryB.DN}) - ds.handleEntry(entryB, []string{entryA.DN}) + ds.handleEntry(entryC, []string{entryB.DN}, "test-search") + ds.handleEntry(entryB, []string{entryA.DN}, "test-search") // A has no deps → written immediately - ds.handleEntry(entryA, nil) + ds.handleEntry(entryA, nil, "test-search") written := ms.entries() if len(written) != 3 { diff --git a/plugin.go b/plugin.go new file mode 100644 index 0000000..0071141 --- /dev/null +++ b/plugin.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "sync" + "time" +) + +type SyncOp string + +const ( + SyncOpCreated SyncOp = "created" + SyncOpUpdated SyncOp = "updated" +) + +// SyncEvent is emitted after a successful target-LDAP write. Plugins receive +// it via Registry.Dispatch and may take side effects (e.g. PVC creation). +// Content is the resolved entry written to target LDAP. Plugins must treat +// it as read-only — the same map may be passed to multiple plugins. +type SyncEvent struct { + SearchID string + DN string + Content map[string]interface{} + Op SyncOp + Timestamp time.Time +} + +// Plugin runs out-of-band side effects in response to successful target-LDAP +// writes. Implementations must be safe for concurrent use: Apply may be +// invoked from many goroutines simultaneously. +type Plugin interface { + Name() string + Match(SyncEvent) bool + Apply(context.Context, SyncEvent) error +} + +// PluginRetry controls per-plugin retry behavior in the registry. +type PluginRetry struct { + MaxAttempts int + InitialDelayMs int + MaxDelayMs int +} + +func (r PluginRetry) withDefaults() PluginRetry { + if r.MaxAttempts <= 0 { + r.MaxAttempts = 5 + } + if r.InitialDelayMs <= 0 { + r.InitialDelayMs = 500 + } + if r.MaxDelayMs <= 0 { + r.MaxDelayMs = 60000 + } + return r +} + +// Registry holds the active plugins and dispatches events to them. +// Dispatch is non-blocking: each plugin runs in its own goroutine with a +// bounded retry loop. Plugin failures are logged but never affect the +// caller (in particular, never block markSyncedAndRelease or LDAP sync). +type Registry struct { + mu sync.RWMutex + plugins []Plugin + retry PluginRetry + wg sync.WaitGroup // tracks in-flight Apply goroutines, used by tests +} + +func NewRegistry(retry PluginRetry) *Registry { + return &Registry{retry: retry.withDefaults()} +} + +func (r *Registry) Register(p Plugin) { + r.mu.Lock() + defer r.mu.Unlock() + r.plugins = append(r.plugins, p) +} + +// Dispatch fans the event out to every matching plugin in its own goroutine. +// Returns immediately. The caller does not need to (and must not) hold any +// dependency-state mutex while invoking this. +func (r *Registry) Dispatch(event SyncEvent) { + r.mu.RLock() + matching := make([]Plugin, 0, len(r.plugins)) + for _, p := range r.plugins { + if p.Match(event) { + matching = append(matching, p) + } + } + retry := r.retry + r.mu.RUnlock() + + if len(matching) == 0 { + return + } + + for _, p := range matching { + p := p + r.wg.Add(1) + go func() { + defer r.wg.Done() + r.runWithRetry(p, event, retry) + }() + } +} + +// Wait blocks until all in-flight plugin goroutines complete. Test-only. +func (r *Registry) Wait() { + r.wg.Wait() +} + +func (r *Registry) runWithRetry(p Plugin, event SyncEvent, retry PluginRetry) { + delay := time.Duration(retry.InitialDelayMs) * time.Millisecond + maxDelay := time.Duration(retry.MaxDelayMs) * time.Millisecond + + for attempt := 1; attempt <= retry.MaxAttempts; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := p.Apply(ctx, event) + cancel() + if err == nil { + if logger != nil { + logger.Debug("Plugin applied", + "Plugin", p.Name(), + "DN", event.DN, + "Op", event.Op, + "Attempt", attempt, + ) + } + return + } + if logger != nil { + logger.Warn("Plugin apply failed", + "Plugin", p.Name(), + "DN", event.DN, + "Op", event.Op, + "Attempt", attempt, + "MaxAttempts", retry.MaxAttempts, + "Err", err, + ) + } + if attempt == retry.MaxAttempts { + if logger != nil { + logger.Error("Plugin apply exhausted retries", + "Plugin", p.Name(), + "DN", event.DN, + "Op", event.Op, + "Err", err, + ) + } + return + } + time.Sleep(delay) + delay *= 2 + if delay > maxDelay { + delay = maxDelay + } + } +} + +// pluginRegistry is the package-level registry consulted by markSyncedAndRelease. +// nil means "no plugins configured" — Dispatch is a no-op. +var pluginRegistry *Registry + +// dispatchSyncEvent is the indirection point used by markSyncedAndRelease so +// tests can swap the registry behavior without touching the global. +var dispatchSyncEvent = func(event SyncEvent) { + if pluginRegistry == nil { + return + } + pluginRegistry.Dispatch(event) +} diff --git a/plugin_log.go b/plugin_log.go new file mode 100644 index 0000000..95a28ba --- /dev/null +++ b/plugin_log.go @@ -0,0 +1,86 @@ +package main + +import ( + "context" + "fmt" + + "gopkg.in/yaml.v2" +) + +// logPlugin is the verification plugin: it logs every event it receives. It +// performs no side effects and never fails. Use it to confirm that plugin +// dispatch is firing exactly when expected (created once per new entry, +// updated on actual change, never on unchanged refreshes) before wiring +// real plugins like the PVC plugin. +type logPlugin struct{} + +func (logPlugin) Name() string { return "log" } +func (logPlugin) Match(_ SyncEvent) bool { return true } +func (logPlugin) Apply(_ context.Context, e SyncEvent) error { + logger.Info("plugin/log: sync event", + "DN", e.DN, + "Op", e.Op, + "SearchID", e.SearchID, + "Timestamp", e.Timestamp, + ) + return nil +} + +// buildPlugin is the factory used by initPluginRegistry to construct a Plugin +// from its configured name. New plugins should be added here. +func buildPlugin(cfg PluginConfig) (Plugin, error) { + switch cfg.Name { + case "log": + return logPlugin{}, nil + case "pvc-group": + var opts pvcGroupConfig + if err := decodeOptions(cfg.Options, &opts); err != nil { + return nil, fmt.Errorf("pvc-group: decode options: %w", err) + } + kc, err := buildKubeClient(opts.Kubeconfig) + if err != nil { + return nil, err + } + return newPVCGroupPlugin(opts, clientGoPVCClient{c: kc}) + default: + return nil, fmt.Errorf("unknown plugin %q", cfg.Name) + } +} + +// decodeOptions round-trips the generic options map through YAML so the +// target struct's yaml tags drive the decode. This avoids hand-writing a +// reflect-based decoder and stays consistent with how the rest of Config is +// loaded. +func decodeOptions(in map[string]interface{}, out interface{}) error { + if in == nil { + return nil + } + raw, err := yaml.Marshal(in) + if err != nil { + return err + } + return yaml.Unmarshal(raw, out) +} + +// initPluginRegistry constructs the package-level pluginRegistry from config. +// Disabled plugins are skipped. Unknown plugin names are logged but do not +// abort startup, so config-only experimentation is safe. +func initPluginRegistry(cfg PluginsConfig) { + pluginRegistry = NewRegistry(PluginRetry{ + MaxAttempts: cfg.Retry.MaxAttempts, + InitialDelayMs: cfg.Retry.InitialDelayMs, + MaxDelayMs: cfg.Retry.MaxDelayMs, + }) + for _, pc := range cfg.Enabled { + if !pc.Enabled { + continue + } + p, err := buildPlugin(pc) + if err != nil { + logger.Warn("Skipping plugin", "Name", pc.Name, "Err", err) + continue + } + pluginRegistry.Register(p) + logger.Info("Plugin registered", "Name", p.Name()) + } +} diff --git a/plugin_pvc.go b/plugin_pvc.go new file mode 100644 index 0000000..e2943d5 --- /dev/null +++ b/plugin_pvc.go @@ -0,0 +1,289 @@ +package main + +import ( + "context" + "fmt" + "path/filepath" + "regexp" + "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" +) + +// pvcGroupConfig is the YAML-decodable shape of the pvc-group plugin's +// options block. It mirrors the keys under PluginConfig.Options. +type pvcGroupConfig struct { + Namespace string `yaml:"namespace"` + StorageClass string `yaml:"storageClass"` + AccessModes []string `yaml:"accessModes"` + Size string `yaml:"size"` + GroupLabelKey string `yaml:"groupLabelKey"` + ExtraLabels map[string]string `yaml:"extraLabels"` + NamePrefix string `yaml:"namePrefix"` + MatchObjectClass string `yaml:"matchObjectClass"` + Kubeconfig string `yaml:"kubeconfig"` +} + +const ( + defaultGroupLabelKey = "helx.renci.org/group-name" + defaultMatchObjectClass = "groupOfNames" + defaultNamePrefix = "group-" + defaultSize = "1Gi" +) + +func (c pvcGroupConfig) withDefaults() pvcGroupConfig { + if c.GroupLabelKey == "" { + c.GroupLabelKey = defaultGroupLabelKey + } + if c.MatchObjectClass == "" { + c.MatchObjectClass = defaultMatchObjectClass + } + if c.NamePrefix == "" { + c.NamePrefix = defaultNamePrefix + } + if c.Size == "" { + c.Size = defaultSize + } + if len(c.AccessModes) == 0 { + c.AccessModes = []string{"ReadWriteMany"} + } + return c +} + +// pvcClient is the slice of kubernetes.Interface the plugin actually uses. +// Tests substitute a fake client implementing this surface. +type pvcClient interface { + List(ctx context.Context, namespace string, labelSelector string) ([]corev1.PersistentVolumeClaim, error) + Create(ctx context.Context, namespace string, pvc *corev1.PersistentVolumeClaim) error +} + +type clientGoPVCClient struct { + c kubernetes.Interface +} + +func (k clientGoPVCClient) List(ctx context.Context, namespace, selector string) ([]corev1.PersistentVolumeClaim, error) { + list, err := k.c.CoreV1().PersistentVolumeClaims(namespace).List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil { + return nil, err + } + return list.Items, nil +} + +func (k clientGoPVCClient) Create(ctx context.Context, namespace string, pvc *corev1.PersistentVolumeClaim) error { + _, err := k.c.CoreV1().PersistentVolumeClaims(namespace).Create(ctx, pvc, metav1.CreateOptions{}) + return err +} + +type pvcGroupPlugin struct { + cfg pvcGroupConfig + client pvcClient + storage resource.Quantity +} + +func newPVCGroupPlugin(cfg pvcGroupConfig, client pvcClient) (*pvcGroupPlugin, error) { + cfg = cfg.withDefaults() + if cfg.Namespace == "" { + return nil, fmt.Errorf("pvc-group: namespace is required") + } + q, err := resource.ParseQuantity(cfg.Size) + if err != nil { + return nil, fmt.Errorf("pvc-group: invalid size %q: %w", cfg.Size, err) + } + return &pvcGroupPlugin{cfg: cfg, client: client, storage: q}, nil +} + +func (p *pvcGroupPlugin) Name() string { return "pvc-group" } + +// Match returns true for entries whose objectClass list contains the +// configured class (default groupOfNames). The objectClass attribute may +// arrive as []string, []interface{}, or a bare string after JSON decoding; +// all three shapes are handled. +func (p *pvcGroupPlugin) Match(e SyncEvent) bool { + if e.Op != SyncOpCreated && e.Op != SyncOpUpdated { + return false + } + raw, ok := e.Content["objectClass"] + if !ok { + return false + } + want := strings.ToLower(p.cfg.MatchObjectClass) + switch v := raw.(type) { + case string: + return strings.EqualFold(v, want) + case []string: + for _, oc := range v { + if strings.EqualFold(oc, want) { + return true + } + } + case []interface{}: + for _, oc := range v { + if s, ok := oc.(string); ok && strings.EqualFold(s, want) { + return true + } + } + } + return false +} + +// groupNameFromContent extracts the canonical group short name. Prefers the +// cn attribute on the entry; falls back to parsing the leading cn= component +// of the DN if cn is missing or unusable. +func groupNameFromContent(dn string, content map[string]interface{}) string { + if raw, ok := content["cn"]; ok { + switch v := raw.(type) { + case string: + if v != "" { + return v + } + case []string: + if len(v) > 0 && v[0] != "" { + return v[0] + } + case []interface{}: + if len(v) > 0 { + if s, ok := v[0].(string); ok && s != "" { + return s + } + } + } + } + // Fallback: parse the first RDN, expecting cn=,... + first := strings.SplitN(dn, ",", 2)[0] + if eq := strings.Index(first, "="); eq != -1 { + return strings.TrimSpace(first[eq+1:]) + } + return "" +} + +// pvcNameSanitizer reduces an arbitrary string to a valid RFC 1123 +// subdomain segment: lowercase, alphanumeric or '-', length-bounded, +// stripped of leading/trailing dashes, with runs of dashes collapsed. +var pvcInvalidRun = regexp.MustCompile(`[^a-z0-9]+`) + +func sanitizePVCName(prefix, name string) string { + combined := strings.ToLower(prefix + name) + combined = pvcInvalidRun.ReplaceAllString(combined, "-") + combined = strings.Trim(combined, "-") + if combined == "" { + combined = "group" + } + if len(combined) > 253 { + combined = strings.TrimRight(combined[:253], "-") + } + return combined +} + +func (p *pvcGroupPlugin) Apply(ctx context.Context, e SyncEvent) error { + groupName := groupNameFromContent(e.DN, e.Content) + if groupName == "" { + return fmt.Errorf("pvc-group: cannot determine group name for DN %q", e.DN) + } + + selector := fmt.Sprintf("%s=%s", p.cfg.GroupLabelKey, groupName) + existing, err := p.client.List(ctx, p.cfg.Namespace, selector) + if err != nil { + return fmt.Errorf("pvc-group: list by label %q: %w", selector, err) + } + if len(existing) > 0 { + if logger != nil { + names := make([]string, len(existing)) + for i, pvc := range existing { + names[i] = pvc.Name + } + logger.Debug("pvc-group: PVC already exists for group; skipping", + "Group", groupName, + "Namespace", p.cfg.Namespace, + "PVCs", names, + ) + } + return nil + } + + pvc := p.buildPVC(groupName) + if err := p.client.Create(ctx, p.cfg.Namespace, pvc); err != nil { + // AlreadyExists from a name collision is a benign race: another + // dispatch raced to the same sanitized name. The label-based + // listing above will catch the duplicate on the next sync. + if apierrors.IsAlreadyExists(err) { + if logger != nil { + logger.Debug("pvc-group: PVC name already exists (benign race)", + "Group", groupName, + "Name", pvc.Name, + ) + } + return nil + } + return fmt.Errorf("pvc-group: create PVC %q: %w", pvc.Name, err) + } + if logger != nil { + logger.Info("pvc-group: created PVC for group", + "Group", groupName, + "Namespace", p.cfg.Namespace, + "Name", pvc.Name, + ) + } + return nil +} + +func (p *pvcGroupPlugin) buildPVC(groupName string) *corev1.PersistentVolumeClaim { + labels := map[string]string{ + p.cfg.GroupLabelKey: groupName, + } + for k, v := range p.cfg.ExtraLabels { + labels[k] = v + } + + modes := make([]corev1.PersistentVolumeAccessMode, 0, len(p.cfg.AccessModes)) + for _, m := range p.cfg.AccessModes { + modes = append(modes, corev1.PersistentVolumeAccessMode(m)) + } + + spec := corev1.PersistentVolumeClaimSpec{ + AccessModes: modes, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: p.storage, + }, + }, + } + if p.cfg.StorageClass != "" { + sc := p.cfg.StorageClass + spec.StorageClassName = &sc + } + + return &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: sanitizePVCName(p.cfg.NamePrefix, groupName), + Namespace: p.cfg.Namespace, + Labels: labels, + }, + Spec: spec, + } +} + +// buildKubeClient returns an in-cluster client when running inside a pod, or +// falls back to the supplied kubeconfig path (or ~/.kube/config) for local +// development. +func buildKubeClient(kubeconfig string) (kubernetes.Interface, error) { + if cfg, err := rest.InClusterConfig(); err == nil { + return kubernetes.NewForConfig(cfg) + } + if kubeconfig == "" { + if home := homedir.HomeDir(); home != "" { + kubeconfig = filepath.Join(home, ".kube", "config") + } + } + cfg, err := clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("pvc-group: build kube config from %q: %w", kubeconfig, err) + } + return kubernetes.NewForConfig(cfg) +} diff --git a/plugin_pvc_test.go b/plugin_pvc_test.go new file mode 100644 index 0000000..fd12ca2 --- /dev/null +++ b/plugin_pvc_test.go @@ -0,0 +1,376 @@ +package main + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// fakePVCClient is a minimal in-memory pvcClient for tests. It enforces: +// label-selector listing, AlreadyExists on duplicate names, and concurrent +// safety so the race tests run deterministically. +type fakePVCClient struct { + mu sync.Mutex + pvcs map[string]*corev1.PersistentVolumeClaim // keyed by ns/name + listErr error + createErr error +} + +func newFakeClient() *fakePVCClient { + return &fakePVCClient{pvcs: map[string]*corev1.PersistentVolumeClaim{}} +} + +func (f *fakePVCClient) key(namespace, name string) string { return namespace + "/" + name } + +func (f *fakePVCClient) List(_ context.Context, namespace, selector string) ([]corev1.PersistentVolumeClaim, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErr != nil { + return nil, f.listErr + } + + wantKey, wantVal, hasSelector := strings.Cut(selector, "=") + out := []corev1.PersistentVolumeClaim{} + for _, pvc := range f.pvcs { + if pvc.Namespace != namespace { + continue + } + if hasSelector { + if pvc.Labels[wantKey] != wantVal { + continue + } + } + out = append(out, *pvc) + } + return out, nil +} + +func (f *fakePVCClient) Create(_ context.Context, namespace string, pvc *corev1.PersistentVolumeClaim) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return f.createErr + } + k := f.key(namespace, pvc.Name) + if _, exists := f.pvcs[k]; exists { + return apierrors.NewAlreadyExists(schema.GroupResource{Resource: "persistentvolumeclaims"}, pvc.Name) + } + cp := pvc.DeepCopy() + cp.Namespace = namespace + f.pvcs[k] = cp + return nil +} + +func (f *fakePVCClient) snapshot() []corev1.PersistentVolumeClaim { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]corev1.PersistentVolumeClaim, 0, len(f.pvcs)) + for _, p := range f.pvcs { + out = append(out, *p) + } + return out +} + +func newTestPlugin(t *testing.T, client pvcClient, overrides ...func(*pvcGroupConfig)) *pvcGroupPlugin { + t.Helper() + cfg := pvcGroupConfig{Namespace: "user-workspaces"} + for _, fn := range overrides { + fn(&cfg) + } + p, err := newPVCGroupPlugin(cfg, client) + if err != nil { + t.Fatalf("newPVCGroupPlugin: %v", err) + } + return p +} + +func eventForGroup(name string, op SyncOp) SyncEvent { + return SyncEvent{ + DN: "cn=" + name + ",ou=groups,dc=example,dc=org", + Content: map[string]interface{}{ + "cn": name, + "objectClass": []string{"top", "groupOfNames"}, + }, + Op: op, + } +} + +// --------------------------------------------------------------------------- +// Match +// --------------------------------------------------------------------------- + +func TestPVC_Match_OnlyGroupOfNames(t *testing.T) { + p := newTestPlugin(t, newFakeClient()) + + if !p.Match(eventForGroup("eagle", SyncOpCreated)) { + t.Errorf("groupOfNames entry should match") + } + + posix := SyncEvent{ + DN: "cn=eagle,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{ + "cn": "eagle", + "objectClass": []string{"top", "posixGroup"}, + }, + Op: SyncOpCreated, + } + if p.Match(posix) { + t.Errorf("posixGroup entry must NOT match — that's the wrong attribute") + } + + user := SyncEvent{ + DN: "uid=alice,ou=users,dc=example,dc=org", + Content: map[string]interface{}{ + "cn": "Alice", + "objectClass": []string{"top", "inetOrgPerson"}, + }, + Op: SyncOpCreated, + } + if p.Match(user) { + t.Errorf("user entry must NOT match") + } +} + +func TestPVC_Match_ObjectClassShapes(t *testing.T) { + p := newTestPlugin(t, newFakeClient()) + + cases := []struct { + name string + oc interface{} + want bool + }{ + {"slice-of-string", []string{"top", "groupOfNames"}, true}, + {"slice-of-interface", []interface{}{"top", "groupOfNames"}, true}, + {"bare-string", "groupOfNames", true}, + {"case-insensitive", []string{"GroupOfNames"}, true}, + {"no-match", []string{"top", "device"}, false}, + {"missing", nil, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + content := map[string]interface{}{"cn": "x"} + if tc.oc != nil { + content["objectClass"] = tc.oc + } + ev := SyncEvent{Op: SyncOpCreated, DN: "cn=x,ou=groups", Content: content} + if got := p.Match(ev); got != tc.want { + t.Errorf("Match(%v) = %v; want %v", tc.oc, got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Apply: create on first sync +// --------------------------------------------------------------------------- + +func TestPVC_Apply_CreatesWithCorrectLabel(t *testing.T) { + resetState(t) + fc := newFakeClient() + p := newTestPlugin(t, fc, func(c *pvcGroupConfig) { + c.StorageClass = "standard" + c.Size = "5Gi" + }) + + if err := p.Apply(context.Background(), eventForGroup("eagle", SyncOpCreated)); err != nil { + t.Fatalf("Apply: %v", err) + } + + got := fc.snapshot() + if len(got) != 1 { + t.Fatalf("expected 1 PVC; got %d", len(got)) + } + pvc := got[0] + if pvc.Namespace != "user-workspaces" { + t.Errorf("namespace = %q", pvc.Namespace) + } + if pvc.Name != "group-eagle" { + t.Errorf("name = %q; want group-eagle", pvc.Name) + } + if pvc.Labels[defaultGroupLabelKey] != "eagle" { + t.Errorf("label %s = %q; want eagle", defaultGroupLabelKey, pvc.Labels[defaultGroupLabelKey]) + } + if pvc.Spec.StorageClassName == nil || *pvc.Spec.StorageClassName != "standard" { + t.Errorf("storageClassName = %v", pvc.Spec.StorageClassName) + } + q := pvc.Spec.Resources.Requests[corev1.ResourceStorage] + if q.String() != "5Gi" { + t.Errorf("storage request = %q; want 5Gi", q.String()) + } +} + +// --------------------------------------------------------------------------- +// Apply: idempotent — second sync of the same group does not duplicate. +// This is THE key requirement: existence determined by label, not by name. +// --------------------------------------------------------------------------- + +func TestPVC_Apply_IdempotentByLabel(t *testing.T) { + fc := newFakeClient() + p := newTestPlugin(t, fc) + + // First sync creates. + if err := p.Apply(context.Background(), eventForGroup("falcon", SyncOpCreated)); err != nil { + t.Fatalf("first Apply: %v", err) + } + // Second sync (e.g. after a restart) reports SyncOpUpdated. + if err := p.Apply(context.Background(), eventForGroup("falcon", SyncOpUpdated)); err != nil { + t.Fatalf("second Apply: %v", err) + } + + got := fc.snapshot() + if len(got) != 1 { + t.Fatalf("expected exactly 1 PVC after re-sync; got %d", len(got)) + } +} + +// --------------------------------------------------------------------------- +// Apply: pre-existing PVC with the right label but a different name is +// respected. This guards against treating the name as the source of truth. +// --------------------------------------------------------------------------- + +func TestPVC_Apply_RespectsPreExistingByLabel(t *testing.T) { + fc := newFakeClient() + // Pre-seed an out-of-band PVC with the right label but a custom name. + fc.pvcs["user-workspaces/legacy-eagle-volume"] = &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "legacy-eagle-volume", + Namespace: "user-workspaces", + Labels: map[string]string{defaultGroupLabelKey: "eagle"}, + }, + } + + p := newTestPlugin(t, fc) + if err := p.Apply(context.Background(), eventForGroup("eagle", SyncOpCreated)); err != nil { + t.Fatalf("Apply: %v", err) + } + + got := fc.snapshot() + if len(got) != 1 { + t.Fatalf("expected exactly 1 PVC (pre-existing one); got %d", len(got)) + } + if got[0].Name != "legacy-eagle-volume" { + t.Errorf("expected pre-existing PVC to be kept; got %q", got[0].Name) + } +} + +// --------------------------------------------------------------------------- +// Apply: AlreadyExists on Create is treated as success. Reproduces the race +// where two dispatches simultaneously list (both see zero) then both Create +// — one wins, the other gets AlreadyExists, and we must not return an error. +// --------------------------------------------------------------------------- + +func TestPVC_Apply_AlreadyExistsIsTolerated(t *testing.T) { + fc := newFakeClient() + // Seed a PVC with the same name but a DIFFERENT label, so the label + // listing returns empty and the plugin proceeds to Create — which then + // hits AlreadyExists. + fc.pvcs["user-workspaces/group-eagle"] = &corev1.PersistentVolumeClaim{ + ObjectMeta: metav1.ObjectMeta{ + Name: "group-eagle", + Namespace: "user-workspaces", + Labels: map[string]string{"some/other": "label"}, + }, + } + p := newTestPlugin(t, fc) + + if err := p.Apply(context.Background(), eventForGroup("eagle", SyncOpCreated)); err != nil { + t.Errorf("AlreadyExists must be tolerated; got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Sanitization +// --------------------------------------------------------------------------- + +func TestPVC_Sanitize(t *testing.T) { + cases := []struct{ in, want string }{ + {"eagle", "group-eagle"}, + {"unc:app:renci:eagle", "group-unc-app-renci-eagle"}, + {"Foo_Bar", "group-foo-bar"}, + {"--leading-trailing--", "group-leading-trailing"}, + {"", "group"}, + } + for _, tc := range cases { + got := sanitizePVCName("group-", tc.in) + if got != tc.want { + t.Errorf("sanitizePVCName(%q) = %q; want %q", tc.in, got, tc.want) + } + } +} + +// --------------------------------------------------------------------------- +// groupNameFromContent: cn attribute, slice forms, DN fallback +// --------------------------------------------------------------------------- + +func TestPVC_GroupNameFromContent(t *testing.T) { + cases := []struct { + name string + dn string + content map[string]interface{} + want string + }{ + {"cn-string", "cn=foo,ou=groups", map[string]interface{}{"cn": "eagle"}, "eagle"}, + {"cn-slice-string", "cn=foo,ou=groups", map[string]interface{}{"cn": []string{"eagle"}}, "eagle"}, + {"cn-slice-interface", "cn=foo,ou=groups", map[string]interface{}{"cn": []interface{}{"eagle"}}, "eagle"}, + {"dn-fallback", "cn=eagle,ou=groups", map[string]interface{}{}, "eagle"}, + {"empty", "", map[string]interface{}{}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := groupNameFromContent(tc.dn, tc.content); got != tc.want { + t.Errorf("got %q; want %q", got, tc.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// Apply: list error is returned (not silently swallowed) so the registry +// retries. +// --------------------------------------------------------------------------- + +func TestPVC_Apply_ListErrorPropagates(t *testing.T) { + fc := newFakeClient() + fc.listErr = errors.New("apiserver unreachable") + p := newTestPlugin(t, fc) + + err := p.Apply(context.Background(), eventForGroup("eagle", SyncOpCreated)) + if err == nil { + t.Fatal("expected error from list failure") + } + if !strings.Contains(err.Error(), "apiserver unreachable") { + t.Errorf("error missing underlying cause: %v", err) + } +} + +// --------------------------------------------------------------------------- +// End-to-end: a SyncEvent dispatched through the registry creates a PVC +// exactly once even on repeated dispatch (label-based idempotency). +// --------------------------------------------------------------------------- + +func TestPVC_Registry_DispatchCreatesOnce(t *testing.T) { + resetState(t) + fc := newFakeClient() + p := newTestPlugin(t, fc) + + reg := NewRegistry(PluginRetry{MaxAttempts: 1, InitialDelayMs: 1, MaxDelayMs: 1}) + reg.Register(p) + pluginRegistry = reg + t.Cleanup(func() { pluginRegistry = nil }) + + for i := 0; i < 3; i++ { + dispatchSyncEvent(eventForGroup("eagle", SyncOpUpdated)) + } + reg.Wait() + + if got := len(fc.snapshot()); got != 1 { + t.Fatalf("expected exactly 1 PVC after repeated dispatch; got %d", got) + } +} diff --git a/plugin_test.go b/plugin_test.go new file mode 100644 index 0000000..5476931 --- /dev/null +++ b/plugin_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" +) + +// recordingPlugin captures every event it receives. Optionally fails the +// first N Apply calls so retry behavior can be exercised. +type recordingPlugin struct { + name string + matchDN string // if non-empty, only match this DN; otherwise match all + failuresLeft atomic.Int32 + mu sync.Mutex + events []SyncEvent +} + +func (p *recordingPlugin) Name() string { return p.name } + +func (p *recordingPlugin) Match(e SyncEvent) bool { + if p.matchDN == "" { + return true + } + return normalizeDN(e.DN) == normalizeDN(p.matchDN) +} + +func (p *recordingPlugin) Apply(_ context.Context, e SyncEvent) error { + if p.failuresLeft.Load() > 0 { + p.failuresLeft.Add(-1) + return errors.New("simulated failure") + } + p.mu.Lock() + p.events = append(p.events, e) + p.mu.Unlock() + return nil +} + +func (p *recordingPlugin) snapshot() []SyncEvent { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]SyncEvent, len(p.events)) + copy(out, p.events) + return out +} + +// installRegistry swaps in a fresh registry plus the given plugins for the +// duration of t. Retry tuned for fast tests. +func installRegistry(t *testing.T, plugins ...Plugin) *Registry { + t.Helper() + reg := NewRegistry(PluginRetry{MaxAttempts: 3, InitialDelayMs: 1, MaxDelayMs: 5}) + for _, p := range plugins { + reg.Register(p) + } + pluginRegistry = reg + t.Cleanup(func() { pluginRegistry = nil }) + return reg +} + +// --------------------------------------------------------------------------- +// Dispatch fires Created exactly once for a new entry. +// --------------------------------------------------------------------------- + +func TestPlugin_FiresCreatedOnNewEntry(t *testing.T) { + resetState(t) + withMockStore(t) + p := &recordingPlugin{name: "rec"} + reg := installRegistry(t, p) + + ds := newDependencyState() + entry := &TransformedEntry{ + DN: "cn=eagle,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "posixGroup"}, + } + ds.handleEntry(entry, nil, "groups-search") + + reg.Wait() + + got := p.snapshot() + if len(got) != 1 { + t.Fatalf("expected 1 dispatched event; got %d", len(got)) + } + if got[0].Op != SyncOpCreated { + t.Errorf("expected SyncOpCreated; got %q", got[0].Op) + } + if got[0].DN != entry.DN { + t.Errorf("DN mismatch: %q", got[0].DN) + } + if got[0].SearchID != "groups-search" { + t.Errorf("SearchID not propagated: %q", got[0].SearchID) + } + if got[0].Timestamp.IsZero() { + t.Errorf("Timestamp should be set") + } +} + +// --------------------------------------------------------------------------- +// A re-sync of the same DN dispatches Updated, not Created. This exercises +// the restart-safety path: after ldap-sync restarts, every search re-runs +// from scratch, and every group will appear fresh in searchResults; but +// target LDAP already has the entry, so storeDestinationLDAP returns +// SyncOpUpdated and plugins must see Updated, not Created again. +// --------------------------------------------------------------------------- + +func TestPlugin_ResyncFiresUpdated(t *testing.T) { + resetState(t) + withMockStore(t) + p := &recordingPlugin{name: "rec"} + reg := installRegistry(t, p) + + ds := newDependencyState() + entry := &TransformedEntry{ + DN: "cn=falcon,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "posixGroup"}, + } + ds.handleEntry(entry, nil, "groups-search") + reg.Wait() + + // Simulate a restart: dependencyState is rebuilt, but target LDAP still + // has the entry, so the mock store reports SyncOpUpdated on the next write. + ds = newDependencyState() + ds.handleEntry(entry, nil, "groups-search") + reg.Wait() + + got := p.snapshot() + if len(got) != 2 { + t.Fatalf("expected 2 events (created + updated); got %d", len(got)) + } + if got[0].Op != SyncOpCreated { + t.Errorf("first event should be Created; got %q", got[0].Op) + } + if got[1].Op != SyncOpUpdated { + t.Errorf("second event should be Updated; got %q", got[1].Op) + } +} + +// --------------------------------------------------------------------------- +// Match filters dispatched events. A plugin that only matches one DN must +// not see events for other DNs. +// --------------------------------------------------------------------------- + +func TestPlugin_MatchFilters(t *testing.T) { + resetState(t) + withMockStore(t) + wanted := "cn=eagle,ou=groups,dc=example,dc=org" + p := &recordingPlugin{name: "rec", matchDN: wanted} + reg := installRegistry(t, p) + + ds := newDependencyState() + ds.handleEntry(&TransformedEntry{ + DN: wanted, + Content: map[string]interface{}{"objectClass": "posixGroup"}, + }, nil, "") + ds.handleEntry(&TransformedEntry{ + DN: "uid=alice,ou=users,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "inetOrgPerson"}, + }, nil, "") + reg.Wait() + + got := p.snapshot() + if len(got) != 1 { + t.Fatalf("expected exactly 1 matched event; got %d", len(got)) + } + if got[0].DN != wanted { + t.Errorf("matched wrong DN: %q", got[0].DN) + } +} + +// --------------------------------------------------------------------------- +// Plugin Apply failures retry up to MaxAttempts and do not block the sync +// pipeline. The mockStore must record the LDAP write immediately, even +// while the plugin is still retrying in the background. +// --------------------------------------------------------------------------- + +func TestPlugin_FailureDoesNotBlockSync(t *testing.T) { + resetState(t) + ms := withMockStore(t) + p := &recordingPlugin{name: "rec"} + p.failuresLeft.Store(2) // two transient failures, succeeds on attempt 3 + reg := installRegistry(t, p) + + ds := newDependencyState() + entry := &TransformedEntry{ + DN: "cn=eagle,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "posixGroup"}, + } + ds.handleEntry(entry, nil, "groups-search") + + // Sync write is synchronous in handleEntry — should already be recorded. + if n := len(ms.entries()); n != 1 { + t.Fatalf("LDAP write should not be blocked by plugin retries; got %d", n) + } + + reg.Wait() + got := p.snapshot() + if len(got) != 1 { + t.Fatalf("expected plugin to eventually succeed; got %d events", len(got)) + } + if p.failuresLeft.Load() != 0 { + t.Errorf("expected all simulated failures consumed; %d left", p.failuresLeft.Load()) + } +} + +// --------------------------------------------------------------------------- +// Plugin permanent failure: exhausts retries and gives up without crashing. +// --------------------------------------------------------------------------- + +func TestPlugin_PermanentFailureExhaustsRetries(t *testing.T) { + resetState(t) + withMockStore(t) + p := &recordingPlugin{name: "rec"} + p.failuresLeft.Store(1000) // far more than MaxAttempts; will never succeed + reg := installRegistry(t, p) + + ds := newDependencyState() + ds.handleEntry(&TransformedEntry{ + DN: "cn=eagle,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "posixGroup"}, + }, nil, "groups-search") + + // Wait deterministically rather than sleeping. + done := make(chan struct{}) + go func() { reg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("registry did not finish after MaxAttempts") + } + + if len(p.snapshot()) != 0 { + t.Errorf("plugin should not have recorded any successful event") + } +} + +// --------------------------------------------------------------------------- +// markSyncedAndRelease called for an already-synced DN must not re-dispatch. +// This guards the early-exit path: the second call with op != "" still +// returns before reaching the dispatch site, otherwise plugins would fire +// twice for the same write. +// --------------------------------------------------------------------------- + +func TestPlugin_NoDispatchOnAlreadySynced(t *testing.T) { + resetState(t) + withMockStore(t) + p := &recordingPlugin{name: "rec"} + reg := installRegistry(t, p) + + ds := newDependencyState() + entry := &TransformedEntry{ + DN: "cn=eagle,ou=groups,dc=example,dc=org", + Content: map[string]interface{}{"objectClass": "posixGroup"}, + } + ds.handleEntry(entry, nil, "groups-search") + reg.Wait() + + // Direct second call simulating a stale release path. + ds.markSyncedAndRelease(entry.DN, "groups-search", entry.Content, SyncOpUpdated) + reg.Wait() + + got := p.snapshot() + if len(got) != 1 { + t.Fatalf("expected exactly 1 dispatch despite duplicate markSynced; got %d", len(got)) + } +} + +// --------------------------------------------------------------------------- +// Empty op is a sentinel meaning "no real write happened" (e.g. the +// transitive markSync calls used during dependency-release recursion). +// Such calls must not dispatch. +// --------------------------------------------------------------------------- + +func TestPlugin_EmptyOpDoesNotDispatch(t *testing.T) { + resetState(t) + withMockStore(t) + p := &recordingPlugin{name: "rec"} + reg := installRegistry(t, p) + + ds := newDependencyState() + ds.markSyncedAndRelease("cn=eagle,ou=groups,dc=example,dc=org", "groups-search", nil, "") + reg.Wait() + + if n := len(p.snapshot()); n != 0 { + t.Errorf("empty op should not dispatch; got %d events", n) + } +}