Stage 3: TerdutEscalationRule + TerdutDeadmanSwitch
CI / test (push) Has been cancelled

Both child CRDs resolve their own teamRef -> TerdutTeam.status via the new
shared resolveTeamAndClient helper (childref.go), never chaining up to
TerdutServer (DESIGN.md §5) -- TerdutTeam.status.serverEndpoint, added in
this same stage, is what makes that literally true.

TerdutEscalationRule: one PUT /api/teams/{id}/escalation per reconcile
(an upsert server-side, confirmed against source), resolving each "user"
target's username to a user_id via GET /api/users first and reporting
Ready: False, reason: UnknownUser if it doesn't resolve. No DELETE exists
for this resource, so its delete path PUTs an empty policy as the closest
available undo.

TerdutDeadmanSwitch: real create/update-in-place/delete, using
terdut-server v0.33.0's PUT (added specifically for this operator). No
unique-name constraint server-side, so idempotent-create here is
GET-list-and-match-by-name rather than adopt-on-409.

Extends tdclient with User/GetUserByUsername, the escalation request types
+ SetEscalation, and DeadmanSwitch + its CRUD methods. Also folds
ConditionTeamReady into the single shared ConditionReady constant, since
both were literally "Ready" and Stage 3 would otherwise have needed a
third same-valued constant.

internal/controller/terdutserver_controller_test.go's fakeTerdutServer
grows GET /api/users, PUT .../escalation, and the full dead man's switch
collection/item routes, replacing the old parseTeamPath/handleTeamByID
pair with a more general parseTeamSubPath/handleTeamSubPath dispatcher
that still covers every existing Stage 1/2 route unchanged.

make fmt lint test build all clean; envtest coverage for
internal/controller: 50.5% -> 71.7%.
This commit is contained in:
Niklas Ye
2026-10-01 13:50:08 +02:00
parent fef60caf06
commit fb9e6a38dc
31 changed files with 2399 additions and 51 deletions
+98
View File
@@ -0,0 +1,98 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// TerdutDeadmanSwitchSpec defines the desired state of TerdutDeadmanSwitch.
//
// One per switch (DESIGN.md §4.4). Reconciled with real update-in-place
// (terdut-server v0.33.0 added PUT specifically for this, §5) -- but with no
// unique-name constraint server-side, idempotent-create here means
// GET-list-and-match-by-name, not adopt-on-409.
type TerdutDeadmanSwitchSpec struct {
// +required
TeamRef TerdutTeamRef `json:"teamRef"`
// name is optional, same as the API: left empty, terdut-server derives
// it from matcher's own canonical form, and that's what the
// idempotent-create lookup matches against too.
// +optional
Name string `json:"name,omitempty"`
// matcher names the alerts this switch watches, e.g.
// "alertname=Watchdog,cluster=prod". One matcher per switch -- add
// another TerdutDeadmanSwitch instead of separating with ";"
// (terdut-server's own restriction, mirrored here so a bad spec is
// rejected at apply time).
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:XValidation:rule="!self.contains(';')",message="one matcher per switch: add another TerdutDeadmanSwitch instead of separating with ;"
Matcher string `json:"matcher"`
// timeout is a Go duration string, e.g. "15m".
// +required
// +kubebuilder:validation:MinLength=1
Timeout string `json:"timeout"`
// +kubebuilder:validation:Enum=critical;error;warning;info
// +kubebuilder:default=critical
// +optional
Severity string `json:"severity,omitempty"`
}
// TerdutDeadmanSwitchStatus defines the observed state of TerdutDeadmanSwitch.
type TerdutDeadmanSwitchStatus struct {
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// switchID is the server-side id.
// +optional
SwitchID int64 `json:"switchID,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Team",type=string,JSONPath=`.spec.teamRef.name`
// +kubebuilder:printcolumn:name="SwitchID",type=integer,JSONPath=`.status.switchID`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// TerdutDeadmanSwitch is the Schema for the terdutdeadmanswitches API
type TerdutDeadmanSwitch struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// spec defines the desired state of TerdutDeadmanSwitch
// +required
Spec TerdutDeadmanSwitchSpec `json:"spec"`
// status defines the observed state of TerdutDeadmanSwitch
// +optional
Status TerdutDeadmanSwitchStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// TerdutDeadmanSwitchList contains a list of TerdutDeadmanSwitch
type TerdutDeadmanSwitchList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []TerdutDeadmanSwitch `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &TerdutDeadmanSwitch{}, &TerdutDeadmanSwitchList{})
return nil
})
}
+137
View File
@@ -0,0 +1,137 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// TerdutTeamRef names the TerdutTeam this resource belongs to. Always
// same-namespace as the CR itself (DESIGN.md §1: only TerdutTeam.spec.serverRef
// crosses namespaces in v1) -- no namespace field, unlike TerdutServerRef.
type TerdutTeamRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// EscalationTargetKind is who one rung of the ladder pages.
// +kubebuilder:validation:Enum=oncall;user
type EscalationTargetKind string
const (
EscalationTargetOncall EscalationTargetKind = "oncall"
EscalationTargetUser EscalationTargetKind = "user"
)
// EscalationTarget is one page within a level. username is required iff
// kind is "user" (terdut-server's own validation, internal/api/escalation.go's
// handleSetEscalation -- mirrored here as a CEL rule so a bad spec is
// rejected at apply time, not discovered on the next failed PUT).
// +kubebuilder:validation:XValidation:rule="self.kind != 'user' || has(self.username)",message="username is required when kind is user"
// +kubebuilder:validation:XValidation:rule="self.kind != 'oncall' || !has(self.username)",message="username must not be set when kind is oncall"
type EscalationTarget struct {
// +required
Kind EscalationTargetKind `json:"kind"`
// +optional
Username string `json:"username,omitempty"`
}
// EscalationLevel is one rung of the ladder: how long to wait, and who to
// page if nobody's acknowledged by then.
type EscalationLevel struct {
// timeout is a Go duration string, e.g. "5m".
// +required
// +kubebuilder:validation:MinLength=1
Timeout string `json:"timeout"`
// +required
// +kubebuilder:validation:MinItems=1
Targets []EscalationTarget `json:"targets"`
}
// TerdutEscalationRuleSpec defines the desired state of TerdutEscalationRule.
//
// One per team (DESIGN.md §4.3) -- terdut-server models a policy as one row
// with an owned list of levels, reconciled with a single whole-policy PUT.
// Not enforced at admission if two CRs name the same team (no webhooks in
// v1, §1); they would simply clobber each other every reconcile.
type TerdutEscalationRuleSpec struct {
// +required
TeamRef TerdutTeamRef `json:"teamRef"`
// +kubebuilder:validation:Minimum=0
// +kubebuilder:validation:Maximum=10
// +optional
RepeatCount int64 `json:"repeatCount,omitempty"`
// +optional
FallbackTopic string `json:"fallbackTopic,omitempty"`
// +required
// +kubebuilder:validation:MinItems=1
Levels []EscalationLevel `json:"levels"`
}
// Condition reasons shared by TerdutEscalationRule and TerdutDeadmanSwitch
// (both resolve a teamRef the same way, DESIGN.md §5).
const (
// ReasonTeamRefNotFound: spec.teamRef names no TerdutTeam (yet).
ReasonTeamRefNotFound = "TeamRefNotFound"
// ReasonWaitingForTeam: the referenced TerdutTeam exists but isn't
// Ready yet (no status.credentialsSecretRef to read).
ReasonWaitingForTeam = "WaitingForTeam"
// ReasonUnknownUser: an escalation target's username doesn't resolve to
// any user server-side (TerdutEscalationRule only).
ReasonUnknownUser = "UnknownUser"
// ReasonChildAdopted: the happy path, shared by both child kinds.
ReasonChildAdopted = "Adopted"
)
// TerdutEscalationRuleStatus defines the observed state of TerdutEscalationRule.
type TerdutEscalationRuleStatus struct {
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:name="Team",type=string,JSONPath=`.spec.teamRef.name`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// TerdutEscalationRule is the Schema for the terdutescalationrules API
type TerdutEscalationRule struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// spec defines the desired state of TerdutEscalationRule
// +required
Spec TerdutEscalationRuleSpec `json:"spec"`
// status defines the observed state of TerdutEscalationRule
// +optional
Status TerdutEscalationRuleStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// TerdutEscalationRuleList contains a list of TerdutEscalationRule
type TerdutEscalationRuleList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []TerdutEscalationRule `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &TerdutEscalationRule{}, &TerdutEscalationRuleList{})
return nil
})
}
+7 -5
View File
@@ -45,11 +45,6 @@ type TerdutTeamSpec struct {
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
}
// Condition types this controller sets on TerdutTeam.
const (
ConditionTeamReady = "Ready"
)
// Condition reasons this controller sets.
const (
// ReasonServerRefNotFound: spec.serverRef names no TerdutServer (yet).
@@ -85,6 +80,13 @@ type TerdutTeamStatus struct {
// +optional
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
// serverEndpoint is the resolved TerdutServer's base URL, resolved once
// here so no child controller (TerdutEscalationRule, TerdutDeadmanSwitch,
// TerdutAlertSource) ever needs its own RBAC on terdutservers just to
// find out where to send a request (DESIGN.md §5).
// +optional
ServerEndpoint string `json:"serverEndpoint,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
+251
View File
@@ -85,6 +85,41 @@ func (in *DeadmanSpec) DeepCopy() *DeadmanSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EscalationLevel) DeepCopyInto(out *EscalationLevel) {
*out = *in
if in.Targets != nil {
in, out := &in.Targets, &out.Targets
*out = make([]EscalationTarget, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EscalationLevel.
func (in *EscalationLevel) DeepCopy() *EscalationLevel {
if in == nil {
return nil
}
out := new(EscalationLevel)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EscalationTarget) DeepCopyInto(out *EscalationTarget) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EscalationTarget.
func (in *EscalationTarget) DeepCopy() *EscalationTarget {
if in == nil {
return nil
}
out := new(EscalationTarget)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ImageSpec) DeepCopyInto(out *ImageSpec) {
*out = *in
@@ -205,6 +240,207 @@ func (in *SweeperSpec) DeepCopy() *SweeperSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutDeadmanSwitch) DeepCopyInto(out *TerdutDeadmanSwitch) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitch.
func (in *TerdutDeadmanSwitch) DeepCopy() *TerdutDeadmanSwitch {
if in == nil {
return nil
}
out := new(TerdutDeadmanSwitch)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutDeadmanSwitch) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutDeadmanSwitchList) DeepCopyInto(out *TerdutDeadmanSwitchList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]TerdutDeadmanSwitch, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchList.
func (in *TerdutDeadmanSwitchList) DeepCopy() *TerdutDeadmanSwitchList {
if in == nil {
return nil
}
out := new(TerdutDeadmanSwitchList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutDeadmanSwitchList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutDeadmanSwitchSpec) DeepCopyInto(out *TerdutDeadmanSwitchSpec) {
*out = *in
out.TeamRef = in.TeamRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchSpec.
func (in *TerdutDeadmanSwitchSpec) DeepCopy() *TerdutDeadmanSwitchSpec {
if in == nil {
return nil
}
out := new(TerdutDeadmanSwitchSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutDeadmanSwitchStatus) DeepCopyInto(out *TerdutDeadmanSwitchStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutDeadmanSwitchStatus.
func (in *TerdutDeadmanSwitchStatus) DeepCopy() *TerdutDeadmanSwitchStatus {
if in == nil {
return nil
}
out := new(TerdutDeadmanSwitchStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutEscalationRule) DeepCopyInto(out *TerdutEscalationRule) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRule.
func (in *TerdutEscalationRule) DeepCopy() *TerdutEscalationRule {
if in == nil {
return nil
}
out := new(TerdutEscalationRule)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutEscalationRule) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutEscalationRuleList) DeepCopyInto(out *TerdutEscalationRuleList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]TerdutEscalationRule, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleList.
func (in *TerdutEscalationRuleList) DeepCopy() *TerdutEscalationRuleList {
if in == nil {
return nil
}
out := new(TerdutEscalationRuleList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutEscalationRuleList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutEscalationRuleSpec) DeepCopyInto(out *TerdutEscalationRuleSpec) {
*out = *in
out.TeamRef = in.TeamRef
if in.Levels != nil {
in, out := &in.Levels, &out.Levels
*out = make([]EscalationLevel, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleSpec.
func (in *TerdutEscalationRuleSpec) DeepCopy() *TerdutEscalationRuleSpec {
if in == nil {
return nil
}
out := new(TerdutEscalationRuleSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutEscalationRuleStatus) DeepCopyInto(out *TerdutEscalationRuleStatus) {
*out = *in
if in.Conditions != nil {
in, out := &in.Conditions, &out.Conditions
*out = make([]v1.Condition, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutEscalationRuleStatus.
func (in *TerdutEscalationRuleStatus) DeepCopy() *TerdutEscalationRuleStatus {
if in == nil {
return nil
}
out := new(TerdutEscalationRuleStatus)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
*out = *in
@@ -403,6 +639,21 @@ func (in *TerdutTeamOIDC) DeepCopy() *TerdutTeamOIDC {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamRef) DeepCopyInto(out *TerdutTeamRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamRef.
func (in *TerdutTeamRef) DeepCopy() *TerdutTeamRef {
if in == nil {
return nil
}
out := new(TerdutTeamRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
*out = *in