-
Notifications
You must be signed in to change notification settings - Fork 30
feat(chart): deny EtcdMember deletion at admission #348
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| {{- /* | ||
| Enforces at the API boundary the contract the docs have carried since day one: | ||
| EtcdMember objects are created and deleted by the operator, not by users. | ||
|
|
||
| Deleting one is not a recoverable mistake. Its data PVC is controller-owned by | ||
| the member, so the volume — and on a Delete-reclaim StorageClass the data | ||
| itself — goes with it, while the member's finalizer removes the member from | ||
| etcd on the way out. A cluster whose members are deleted one by one therefore | ||
| dismembers itself and leaves nothing to restore from. | ||
|
|
||
| Nothing manages EtcdMember objects declaratively (the only sanctioned | ||
| non-operator writer is cmd/etcd-migrate, which creates and never deletes), so a | ||
| DELETE from anywhere else is always an accident or a tracking misconfiguration | ||
| — stopped at the boundary, with the requester told why. | ||
|
|
||
| Allowed through: | ||
| - the operator's own ServiceAccount — scale-down and crash-loop replacement | ||
| delete members deliberately; | ||
| - the garbage collector — cascade from a deleted EtcdCluster must still work; | ||
| - the namespace controller — deleting a namespace must not hang; | ||
| - kube-controller-manager itself — on clusters run without | ||
| --use-service-account-credentials the GC and namespace-cleanup deletes | ||
| authenticate as the user system:kube-controller-manager rather than the | ||
| per-controller ServiceAccounts above. | ||
|
|
||
| Break-glass without uninstalling the policy: annotate the member with | ||
| etcd-operator.cozystack.io/allow-deletion=true, then delete it. | ||
|
|
||
| Requires Kubernetes 1.30+ (ValidatingAdmissionPolicy GA). On an older | ||
| apiserver the install fails with "no matches for kind | ||
| ValidatingAdmissionPolicy"; set memberDeletionProtection.enabled=false to | ||
| install without the guard. | ||
|
|
||
| Rendered unconditionally (NOT gated on .Capabilities.APIVersions): capability | ||
| detection would make a security guard's absence silent — a pre-1.30 `helm | ||
| install`, or a GitOps render carrying the destination's API versions, would | ||
| quietly omit it. Absence of a guard against an unrecoverable accident must be | ||
| loud and explicit: always render, and opt out via | ||
| memberDeletionProtection.enabled=false. | ||
| */ -}} | ||
| {{- if .Values.memberDeletionProtection.enabled }} | ||
| {{- $allowed := concat | ||
| (list | ||
| (printf "system:serviceaccount:%s:%s" .Release.Namespace (include "etcd-operator.serviceAccountName" .)) | ||
| "system:serviceaccount:kube-system:generic-garbage-collector" | ||
| "system:serviceaccount:kube-system:namespace-controller" | ||
| "system:kube-controller-manager") | ||
| .Values.memberDeletionProtection.additionalAllowedUsers }} | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicy | ||
| metadata: | ||
| name: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| labels: | ||
| {{- include "etcd-operator.labels" . | nindent 4 }} | ||
| spec: | ||
| failurePolicy: Fail | ||
| matchConstraints: | ||
| resourceRules: | ||
| - apiGroups: ["etcd-operator.cozystack.io"] | ||
| apiVersions: ["*"] | ||
| operations: ["DELETE"] | ||
| resources: ["etcdmembers"] | ||
| validations: | ||
| - expression: >- | ||
| request.userInfo.username in {{ $allowed | toJson }} | ||
| || (has(oldObject.metadata.annotations) | ||
| && "etcd-operator.cozystack.io/allow-deletion" in oldObject.metadata.annotations | ||
| && oldObject.metadata.annotations["etcd-operator.cozystack.io/allow-deletion"] == "true") | ||
| reason: Forbidden | ||
| messageExpression: >- | ||
| "EtcdMember " + oldObject.metadata.name + " is managed exclusively by etcd-operator, and deleting it | ||
| removes the member from etcd and releases its data volume — the data is not recoverable from here. | ||
| Scale the EtcdCluster instead. If you really mean it, annotate the member with | ||
| etcd-operator.cozystack.io/allow-deletion=true first." | ||
| --- | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicyBinding | ||
| metadata: | ||
| name: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| labels: | ||
| {{- include "etcd-operator.labels" . | nindent 4 }} | ||
| spec: | ||
| policyName: {{ include "etcd-operator.fullname" . }}-protect-members | ||
| validationActions: ["Deny"] | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| {{- with .Values.manager.watchNamespaces }} | ||
| # Scope to the watched namespaces so multiple namespace-scoped releases don't | ||
| # deny each other's members (admission is deny-wins; each allowlist holds only | ||
| # its own operator SA). Cluster-wide install must be a singleton — see values.yaml. | ||
| matchResources: | ||
| namespaceSelector: | ||
| matchExpressions: | ||
| - key: kubernetes.io/metadata.name | ||
| operator: In | ||
| values: | ||
| {{- range . }} | ||
| - {{ . | quote }} | ||
| {{- end }} | ||
| {{- end }} | ||
| {{- end }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,165 @@ | ||
| suite: EtcdMember deletion is denied at admission | ||
|
|
||
| # The policy is the enforcement point for a contract the docs have always | ||
| # stated: EtcdMember objects belong to the operator. What matters in these | ||
| # assertions is not the YAML shape but who keeps the ability to delete — | ||
| # getting that list wrong either wedges cluster/namespace deletion (too | ||
| # strict) or leaves the hole open (too loose). | ||
|
|
||
| templates: | ||
| - member-deletion-policy.yaml | ||
|
|
||
| tests: | ||
| - it: is installed by default | ||
| asserts: | ||
| - hasDocuments: | ||
| count: 2 | ||
| - containsDocument: | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicy | ||
| name: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 0 | ||
| - containsDocument: | ||
| apiVersion: admissionregistration.k8s.io/v1 | ||
| kind: ValidatingAdmissionPolicyBinding | ||
| name: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 1 | ||
| # The binding must name the policy it enforces — a typo here installs a | ||
| # policy that matches nothing and silently protects nothing. | ||
| - equal: | ||
| path: spec.policyName | ||
| value: RELEASE-NAME-etcd-operator-protect-members | ||
| documentIndex: 1 | ||
|
|
||
| - it: matches only DELETE on etcdmembers | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].operations | ||
| value: ["DELETE"] | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].resources | ||
| value: ["etcdmembers"] | ||
| - equal: | ||
| path: spec.matchConstraints.resourceRules[0].apiGroups | ||
| value: ["etcd-operator.cozystack.io"] | ||
|
|
||
| - it: denies rather than warns, and fails closed | ||
| asserts: | ||
| - equal: | ||
| path: spec.validationActions | ||
| value: ["Deny"] | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| - equal: | ||
| path: spec.failurePolicy | ||
| value: Fail | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
|
|
||
| # The operator deletes members itself on scale-down and crash-loop | ||
| # replacement; the GC has to cascade from a deleted EtcdCluster; the | ||
| # namespace controller has to finish a namespace deletion. Denying any of | ||
| # the three turns a routine operation into a wedge. | ||
| - it: still allows the operator, the garbage collector, the namespace controller and kube-controller-manager | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:NAMESPACE:RELEASE-NAME-etcd-operator | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:kube-system:generic-garbage-collector | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:kube-system:namespace-controller | ||
| # Clusters without --use-service-account-credentials run GC and namespace | ||
| # cleanup as the user system:kube-controller-manager, not per-controller SAs. | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:kube-controller-manager | ||
|
|
||
| - it: honours the break-glass annotation | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: etcd-operator\.cozystack\.io/allow-deletion | ||
|
|
||
| # A platform whose own controller legitimately reaps members needs a way in | ||
| # that does not mean disabling the guard outright. | ||
| - it: accepts additional allowed users | ||
| set: | ||
| memberDeletionProtection: | ||
| additionalAllowedUsers: | ||
| - system:serviceaccount:platform:reaper | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:platform:reaper | ||
|
|
||
| # Kubernetes below 1.30 has no ValidatingAdmissionPolicy; the chart must | ||
| # still install there. | ||
| - it: can be switched off for older apiservers | ||
| set: | ||
| memberDeletionProtection: | ||
| enabled: false | ||
| asserts: | ||
| - hasDocuments: | ||
| count: 0 | ||
|
|
||
| - it: names the ServiceAccount the release actually uses | ||
| set: | ||
| serviceAccount: | ||
| create: false | ||
| name: byo-sa | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicy | ||
| asserts: | ||
| - matchRegex: | ||
| path: spec.validations[0].expression | ||
| pattern: system:serviceaccount:NAMESPACE:byo-sa | ||
|
|
||
| # Cluster-wide by default (no watchNamespaces): the binding must match every | ||
| # namespace, so it carries no matchResources scoping. | ||
| - it: binds cluster-wide when the operator watches all namespaces | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| asserts: | ||
| - notExists: | ||
| path: spec.matchResources | ||
|
|
||
| # Namespace-scoped operator: the binding must scope to the watched namespaces | ||
| # so two scoped releases don't deny each other's members. | ||
| - it: scopes the binding to the watched namespaces when set | ||
| set: | ||
| manager: | ||
| watchNamespaces: | ||
| - team-a | ||
| - team-b | ||
| documentSelector: | ||
| path: kind | ||
| value: ValidatingAdmissionPolicyBinding | ||
| asserts: | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].key | ||
| value: kubernetes.io/metadata.name | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].operator | ||
| value: In | ||
| - equal: | ||
| path: spec.matchResources.namespaceSelector.matchExpressions[0].values | ||
| value: [team-a, team-b] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -214,14 +214,20 @@ Manual recovery: | |
| ```sh | ||
| # 1. Identify the broken member. | ||
| kubectl get etcdmember.etcd-operator.cozystack.io -n <ns> | ||
| # 2. Delete it — the finalizer runs MemberRemove against peers, then GC takes | ||
| # 2. Deleting a member is denied by default (see concepts: member deletion is | ||
| # denied at admission) — this is the deliberate exception, so unlock it: | ||
| kubectl annotate etcdmember.etcd-operator.cozystack.io <broken-member> -n <ns> \ | ||
| etcd-operator.cozystack.io/allow-deletion=true | ||
|
Comment on lines
+219
to
+220
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
sed -n '210,228p' docs/operations.md
printf '\nRelevant annotation commands:\n'
rg -n -C 3 'kubectl annotate|allow-deletion' docs/operations.mdRepository: cozystack/etcd-operator Length of output: 1966 🌐 Web query:
💡 Result: In Kubernetes, the kubectl annotate command is used to add or update annotations on resources [1][2]. The behavior regarding existing annotations is as follows: 1. Default Behavior: If you attempt to set an annotation that already exists on a resource without using the --overwrite flag, the command will fail [1][3]. 2. Using --overwrite: You must include the --overwrite flag to update an annotation that is already present [1][4]. When this flag is set to true, it permits the command to overwrite the existing value of the specified annotation [1][5]. 3. Removing Annotations: If you wish to remove an existing annotation, you can append a hyphen (-) to the end of the annotation key (e.g., kubectl annotate pods foo description-). This action does not require the --overwrite flag [1][5]. Official documentation specifies the flag as --overwrite [1][2]. While some older interface outputs might display it as --overwrite=false by default, it functions as a boolean toggle [6]. If you specify --overwrite, it is treated as true [6]. Citations:
Add If the annotation already exists, 🤖 Prompt for AI AgentsSource: MCP tools |
||
| # 3. Delete it — the finalizer runs MemberRemove against peers, then GC takes | ||
| # the Pod and PVC. Quorum holds because we remove before adding. | ||
| kubectl delete etcdmember.etcd-operator.cozystack.io <broken-member> -n <ns> | ||
| # 3. The cluster controller's next reconcile observes current < desired and | ||
| # 4. The cluster controller's next reconcile observes current < desired and | ||
| # scales up automatically — a new member is added with GenerateName and | ||
| # fresh storage. | ||
| ``` | ||
|
|
||
| The annotation step is the point of the guard: this recovery discards a data volume on purpose, and typing that out is what separates it from the same command issued by accident. | ||
|
|
||
| This sequence preserves quorum if you have an odd number of voters and only one is broken. If multiple voters are broken simultaneously, quorum is lost and you can't `MemberRemove` cleanly. In that case the recovery is to delete the EtcdCluster, recreate it, and restore from a snapshot — see [Restoring a cluster from a snapshot](#restoring-a-cluster-from-a-snapshot). Snapshots only exist if you have been taking `EtcdSnapshot`s, so set that up *before* you need it. | ||
|
|
||
| ## Taking a snapshot | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.