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:
@@ -27,4 +27,22 @@ resources:
|
|||||||
kind: TerdutTeam
|
kind: TerdutTeam
|
||||||
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
||||||
version: v1alpha1
|
version: v1alpha1
|
||||||
|
- api:
|
||||||
|
crdVersion: v1
|
||||||
|
namespaced: true
|
||||||
|
controller: true
|
||||||
|
domain: ryuvia.com
|
||||||
|
group: terdut
|
||||||
|
kind: TerdutEscalationRule
|
||||||
|
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
||||||
|
version: v1alpha1
|
||||||
|
- api:
|
||||||
|
crdVersion: v1
|
||||||
|
namespaced: true
|
||||||
|
controller: true
|
||||||
|
domain: ryuvia.com
|
||||||
|
group: terdut
|
||||||
|
kind: TerdutDeadmanSwitch
|
||||||
|
path: git.ryuvia.com/niklas/terdut-operator/api/v1alpha1
|
||||||
|
version: v1alpha1
|
||||||
version: "3"
|
version: "3"
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -45,11 +45,6 @@ type TerdutTeamSpec struct {
|
|||||||
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition types this controller sets on TerdutTeam.
|
|
||||||
const (
|
|
||||||
ConditionTeamReady = "Ready"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Condition reasons this controller sets.
|
// Condition reasons this controller sets.
|
||||||
const (
|
const (
|
||||||
// ReasonServerRefNotFound: spec.serverRef names no TerdutServer (yet).
|
// ReasonServerRefNotFound: spec.serverRef names no TerdutServer (yet).
|
||||||
@@ -85,6 +80,13 @@ type TerdutTeamStatus struct {
|
|||||||
// +optional
|
// +optional
|
||||||
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
|
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
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,41 @@ func (in *DeadmanSpec) DeepCopy() *DeadmanSpec {
|
|||||||
return out
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *ImageSpec) DeepCopyInto(out *ImageSpec) {
|
func (in *ImageSpec) DeepCopyInto(out *ImageSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -205,6 +240,207 @@ func (in *SweeperSpec) DeepCopy() *SweeperSpec {
|
|||||||
return out
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
|
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -403,6 +639,21 @@ func (in *TerdutTeamOIDC) DeepCopy() *TerdutTeamOIDC {
|
|||||||
return out
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
||||||
*out = *in
|
*out = *in
|
||||||
|
|||||||
+16
@@ -193,6 +193,22 @@ func main() {
|
|||||||
setupLog.Error(err, "Failed to create controller", "controller", "terdutteam")
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutteam")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if err := (&controller.TerdutEscalationRuleReconciler{
|
||||||
|
Client: mgr.GetClient(),
|
||||||
|
Scheme: mgr.GetScheme(),
|
||||||
|
OperatorNamespace: operatorNamespace,
|
||||||
|
}).SetupWithManager(mgr); err != nil {
|
||||||
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutescalationrule")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := (&controller.TerdutDeadmanSwitchReconciler{
|
||||||
|
Client: mgr.GetClient(),
|
||||||
|
Scheme: mgr.GetScheme(),
|
||||||
|
OperatorNamespace: operatorNamespace,
|
||||||
|
}).SetupWithManager(mgr); err != nil {
|
||||||
|
setupLog.Error(err, "Failed to create controller", "controller", "terdutdeadmanswitch")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
// +kubebuilder:scaffold:builder
|
// +kubebuilder:scaffold:builder
|
||||||
|
|
||||||
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.22.0
|
||||||
|
name: terdutdeadmanswitches.terdut.ryuvia.com
|
||||||
|
spec:
|
||||||
|
group: terdut.ryuvia.com
|
||||||
|
names:
|
||||||
|
kind: TerdutDeadmanSwitch
|
||||||
|
listKind: TerdutDeadmanSwitchList
|
||||||
|
plural: terdutdeadmanswitches
|
||||||
|
singular: terdutdeadmanswitch
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- additionalPrinterColumns:
|
||||||
|
- jsonPath: .spec.teamRef.name
|
||||||
|
name: Team
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.switchID
|
||||||
|
name: SwitchID
|
||||||
|
type: integer
|
||||||
|
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||||
|
name: Ready
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
||||||
|
name: Reason
|
||||||
|
type: string
|
||||||
|
name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: TerdutDeadmanSwitch is the Schema for the terdutdeadmanswitches
|
||||||
|
API
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: spec defines the desired state of TerdutDeadmanSwitch
|
||||||
|
properties:
|
||||||
|
matcher:
|
||||||
|
description: |-
|
||||||
|
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).
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: 'one matcher per switch: add another TerdutDeadmanSwitch
|
||||||
|
instead of separating with ;'
|
||||||
|
rule: '!self.contains('';'')'
|
||||||
|
name:
|
||||||
|
description: |-
|
||||||
|
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.
|
||||||
|
type: string
|
||||||
|
severity:
|
||||||
|
default: critical
|
||||||
|
enum:
|
||||||
|
- critical
|
||||||
|
- error
|
||||||
|
- warning
|
||||||
|
- info
|
||||||
|
type: string
|
||||||
|
teamRef:
|
||||||
|
description: |-
|
||||||
|
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.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "15m".
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- matcher
|
||||||
|
- teamRef
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
description: status defines the observed state of TerdutDeadmanSwitch
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
items:
|
||||||
|
description: Condition contains details for one aspect of the current
|
||||||
|
state of this API Resource.
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: |-
|
||||||
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||||
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
description: |-
|
||||||
|
message is a human readable message indicating details about the transition.
|
||||||
|
This may be an empty string.
|
||||||
|
maxLength: 32768
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: |-
|
||||||
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||||
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||||
|
with respect to the current state of the instance.
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
description: |-
|
||||||
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||||
|
Producers of specific condition types may define expected values and meanings for this field,
|
||||||
|
and whether the values are considered a guaranteed API.
|
||||||
|
The value should be a CamelCase string.
|
||||||
|
This field may not be empty.
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: status of the condition, one of True, False, Unknown.
|
||||||
|
enum:
|
||||||
|
- "True"
|
||||||
|
- "False"
|
||||||
|
- Unknown
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
maxLength: 316
|
||||||
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- lastTransitionTime
|
||||||
|
- message
|
||||||
|
- reason
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- type
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
observedGeneration:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
switchID:
|
||||||
|
description: switchID is the server-side id.
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- spec
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
---
|
||||||
|
apiVersion: apiextensions.k8s.io/v1
|
||||||
|
kind: CustomResourceDefinition
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
controller-gen.kubebuilder.io/version: v0.22.0
|
||||||
|
name: terdutescalationrules.terdut.ryuvia.com
|
||||||
|
spec:
|
||||||
|
group: terdut.ryuvia.com
|
||||||
|
names:
|
||||||
|
kind: TerdutEscalationRule
|
||||||
|
listKind: TerdutEscalationRuleList
|
||||||
|
plural: terdutescalationrules
|
||||||
|
singular: terdutescalationrule
|
||||||
|
scope: Namespaced
|
||||||
|
versions:
|
||||||
|
- additionalPrinterColumns:
|
||||||
|
- jsonPath: .spec.teamRef.name
|
||||||
|
name: Team
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
||||||
|
name: Ready
|
||||||
|
type: string
|
||||||
|
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
||||||
|
name: Reason
|
||||||
|
type: string
|
||||||
|
name: v1alpha1
|
||||||
|
schema:
|
||||||
|
openAPIV3Schema:
|
||||||
|
description: TerdutEscalationRule is the Schema for the terdutescalationrules
|
||||||
|
API
|
||||||
|
properties:
|
||||||
|
apiVersion:
|
||||||
|
description: |-
|
||||||
|
APIVersion defines the versioned schema of this representation of an object.
|
||||||
|
Servers should convert recognized schemas to the latest internal value, and
|
||||||
|
may reject unrecognized values.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||||
|
type: string
|
||||||
|
kind:
|
||||||
|
description: |-
|
||||||
|
Kind is a string value representing the REST resource this object represents.
|
||||||
|
Servers may infer this from the endpoint the client submits requests to.
|
||||||
|
Cannot be updated.
|
||||||
|
In CamelCase.
|
||||||
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||||
|
type: string
|
||||||
|
metadata:
|
||||||
|
type: object
|
||||||
|
spec:
|
||||||
|
description: spec defines the desired state of TerdutEscalationRule
|
||||||
|
properties:
|
||||||
|
fallbackTopic:
|
||||||
|
type: string
|
||||||
|
levels:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
EscalationLevel is one rung of the ladder: how long to wait, and who to
|
||||||
|
page if nobody's acknowledged by then.
|
||||||
|
properties:
|
||||||
|
targets:
|
||||||
|
items:
|
||||||
|
description: |-
|
||||||
|
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).
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
description: EscalationTargetKind is who one rung of the
|
||||||
|
ladder pages.
|
||||||
|
enum:
|
||||||
|
- oncall
|
||||||
|
- user
|
||||||
|
type: string
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- kind
|
||||||
|
type: object
|
||||||
|
x-kubernetes-validations:
|
||||||
|
- message: username is required when kind is user
|
||||||
|
rule: self.kind != 'user' || has(self.username)
|
||||||
|
- message: username must not be set when kind is oncall
|
||||||
|
rule: self.kind != 'oncall' || !has(self.username)
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
timeout:
|
||||||
|
description: timeout is a Go duration string, e.g. "5m".
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- targets
|
||||||
|
- timeout
|
||||||
|
type: object
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
repeatCount:
|
||||||
|
format: int64
|
||||||
|
maximum: 10
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
teamRef:
|
||||||
|
description: |-
|
||||||
|
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.
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- name
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- levels
|
||||||
|
- teamRef
|
||||||
|
type: object
|
||||||
|
status:
|
||||||
|
description: status defines the observed state of TerdutEscalationRule
|
||||||
|
properties:
|
||||||
|
conditions:
|
||||||
|
items:
|
||||||
|
description: Condition contains details for one aspect of the current
|
||||||
|
state of this API Resource.
|
||||||
|
properties:
|
||||||
|
lastTransitionTime:
|
||||||
|
description: |-
|
||||||
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||||
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
message:
|
||||||
|
description: |-
|
||||||
|
message is a human readable message indicating details about the transition.
|
||||||
|
This may be an empty string.
|
||||||
|
maxLength: 32768
|
||||||
|
type: string
|
||||||
|
observedGeneration:
|
||||||
|
description: |-
|
||||||
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||||
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||||
|
with respect to the current state of the instance.
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
|
type: integer
|
||||||
|
reason:
|
||||||
|
description: |-
|
||||||
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||||
|
Producers of specific condition types may define expected values and meanings for this field,
|
||||||
|
and whether the values are considered a guaranteed API.
|
||||||
|
The value should be a CamelCase string.
|
||||||
|
This field may not be empty.
|
||||||
|
maxLength: 1024
|
||||||
|
minLength: 1
|
||||||
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||||
|
type: string
|
||||||
|
status:
|
||||||
|
description: status of the condition, one of True, False, Unknown.
|
||||||
|
enum:
|
||||||
|
- "True"
|
||||||
|
- "False"
|
||||||
|
- Unknown
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
maxLength: 316
|
||||||
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- lastTransitionTime
|
||||||
|
- message
|
||||||
|
- reason
|
||||||
|
- status
|
||||||
|
- type
|
||||||
|
type: object
|
||||||
|
type: array
|
||||||
|
x-kubernetes-list-map-keys:
|
||||||
|
- type
|
||||||
|
x-kubernetes-list-type: map
|
||||||
|
observedGeneration:
|
||||||
|
format: int64
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- spec
|
||||||
|
type: object
|
||||||
|
served: true
|
||||||
|
storage: true
|
||||||
|
subresources:
|
||||||
|
status: {}
|
||||||
@@ -171,6 +171,13 @@ spec:
|
|||||||
observedGeneration:
|
observedGeneration:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
serverEndpoint:
|
||||||
|
description: |-
|
||||||
|
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).
|
||||||
|
type: string
|
||||||
teamID:
|
teamID:
|
||||||
description: |-
|
description: |-
|
||||||
teamID is the server-side id -- needed by every child object's
|
teamID is the server-side id -- needed by every child object's
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
resources:
|
resources:
|
||||||
- bases/terdut.ryuvia.com_terdutservers.yaml
|
- bases/terdut.ryuvia.com_terdutservers.yaml
|
||||||
- bases/terdut.ryuvia.com_terdutteams.yaml
|
- bases/terdut.ryuvia.com_terdutteams.yaml
|
||||||
|
- bases/terdut.ryuvia.com_terdutescalationrules.yaml
|
||||||
|
- bases/terdut.ryuvia.com_terdutdeadmanswitches.yaml
|
||||||
# +kubebuilder:scaffold:crdkustomizeresource
|
# +kubebuilder:scaffold:crdkustomizeresource
|
||||||
|
|
||||||
patches:
|
patches:
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ resources:
|
|||||||
# default, aiding admins in cluster management. Those roles are
|
# default, aiding admins in cluster management. Those roles are
|
||||||
# not used by the terdut-operator itself. You can comment the following lines
|
# not used by the terdut-operator itself. You can comment the following lines
|
||||||
# if you do not want those helpers be installed with your Project.
|
# if you do not want those helpers be installed with your Project.
|
||||||
|
- terdutdeadmanswitch_admin_role.yaml
|
||||||
|
- terdutdeadmanswitch_editor_role.yaml
|
||||||
|
- terdutdeadmanswitch_viewer_role.yaml
|
||||||
|
- terdutescalationrule_admin_role.yaml
|
||||||
|
- terdutescalationrule_editor_role.yaml
|
||||||
|
- terdutescalationrule_viewer_role.yaml
|
||||||
- terdutteam_admin_role.yaml
|
- terdutteam_admin_role.yaml
|
||||||
- terdutteam_editor_role.yaml
|
- terdutteam_editor_role.yaml
|
||||||
- terdutteam_viewer_role.yaml
|
- terdutteam_viewer_role.yaml
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ rules:
|
|||||||
- apiGroups:
|
- apiGroups:
|
||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
|
- terdutdeadmanswitches
|
||||||
|
- terdutescalationrules
|
||||||
- terdutservers
|
- terdutservers
|
||||||
- terdutteams
|
- terdutteams
|
||||||
verbs:
|
verbs:
|
||||||
@@ -68,6 +70,8 @@ rules:
|
|||||||
- apiGroups:
|
- apiGroups:
|
||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
|
- terdutdeadmanswitches/finalizers
|
||||||
|
- terdutescalationrules/finalizers
|
||||||
- terdutservers/finalizers
|
- terdutservers/finalizers
|
||||||
- terdutteams/finalizers
|
- terdutteams/finalizers
|
||||||
verbs:
|
verbs:
|
||||||
@@ -75,6 +79,8 @@ rules:
|
|||||||
- apiGroups:
|
- apiGroups:
|
||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
|
- terdutdeadmanswitches/status
|
||||||
|
- terdutescalationrules/status
|
||||||
- terdutservers/status
|
- terdutservers/status
|
||||||
- terdutteams/status
|
- terdutteams/status
|
||||||
verbs:
|
verbs:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants full permissions ('*') over terdut.ryuvia.com.
|
||||||
|
# This role is intended for users authorized to modify roles and bindings within the cluster,
|
||||||
|
# enabling them to delegate specific permissions to other users or groups as needed.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutdeadmanswitch-admin-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches
|
||||||
|
verbs:
|
||||||
|
- '*'
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
|
||||||
|
# This role is intended for users who need to manage these resources
|
||||||
|
# but should not control RBAC or manage permissions for others.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutdeadmanswitch-editor-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants read-only access to terdut.ryuvia.com resources.
|
||||||
|
# This role is intended for users who need visibility into these resources
|
||||||
|
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutdeadmanswitch-viewer-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutdeadmanswitches/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants full permissions ('*') over terdut.ryuvia.com.
|
||||||
|
# This role is intended for users authorized to modify roles and bindings within the cluster,
|
||||||
|
# enabling them to delegate specific permissions to other users or groups as needed.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutescalationrule-admin-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules
|
||||||
|
verbs:
|
||||||
|
- '*'
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants permissions to create, update, and delete resources within the terdut.ryuvia.com.
|
||||||
|
# This role is intended for users who need to manage these resources
|
||||||
|
# but should not control RBAC or manage permissions for others.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutescalationrule-editor-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# This rule is not used by the project terdut-operator itself.
|
||||||
|
# It is provided to allow the cluster admin to help manage permissions for users.
|
||||||
|
#
|
||||||
|
# Grants read-only access to terdut.ryuvia.com resources.
|
||||||
|
# This role is intended for users who need visibility into these resources
|
||||||
|
# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
|
||||||
|
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutescalationrule-viewer-role
|
||||||
|
rules:
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- terdut.ryuvia.com
|
||||||
|
resources:
|
||||||
|
- terdutescalationrules/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
@@ -2,4 +2,6 @@
|
|||||||
resources:
|
resources:
|
||||||
- terdut_v1alpha1_terdutserver.yaml
|
- terdut_v1alpha1_terdutserver.yaml
|
||||||
- terdut_v1alpha1_terdutteam.yaml
|
- terdut_v1alpha1_terdutteam.yaml
|
||||||
|
- terdut_v1alpha1_terdutescalationrule.yaml
|
||||||
|
- terdut_v1alpha1_terdutdeadmanswitch.yaml
|
||||||
# +kubebuilder:scaffold:manifestskustomizesamples
|
# +kubebuilder:scaffold:manifestskustomizesamples
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
|
kind: TerdutDeadmanSwitch
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutdeadmanswitch-sample
|
||||||
|
spec:
|
||||||
|
teamRef:
|
||||||
|
name: terdutteam-sample
|
||||||
|
# name is optional -- left empty, terdut-server derives it from matcher's
|
||||||
|
# own canonical form (DESIGN.md §4.4).
|
||||||
|
matcher: "alertname=Watchdog"
|
||||||
|
timeout: 15m
|
||||||
|
severity: critical
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
|
kind: TerdutEscalationRule
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: terdut-operator
|
||||||
|
app.kubernetes.io/managed-by: kustomize
|
||||||
|
name: terdutescalationrule-sample
|
||||||
|
spec:
|
||||||
|
# One per team (DESIGN.md §4.3) -- a second TerdutEscalationRule naming
|
||||||
|
# the same teamRef would simply clobber this one every reconcile, since
|
||||||
|
# there's no admission-time check for it in v1.
|
||||||
|
teamRef:
|
||||||
|
name: terdutteam-sample
|
||||||
|
repeatCount: 2
|
||||||
|
fallbackTopic: platform-fallback
|
||||||
|
levels:
|
||||||
|
# username is required iff kind is "user", and rejected otherwise --
|
||||||
|
# enforced at apply time via CEL (api/v1alpha1/terdutescalationrule_types.go).
|
||||||
|
- timeout: 5m
|
||||||
|
targets:
|
||||||
|
- kind: user
|
||||||
|
username: alice
|
||||||
|
- timeout: 10m
|
||||||
|
targets:
|
||||||
|
- kind: oncall
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// childError carries a condition reason/message, the same role teamError
|
||||||
|
// and databaseError play for their own controllers: an expected,
|
||||||
|
// requeue-and-retry outcome, not a reconcile failure.
|
||||||
|
type childError struct {
|
||||||
|
reason string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *childError) Error() string { return e.message }
|
||||||
|
|
||||||
|
// resolveTeamAndClient implements DESIGN.md §5's "every child resolves its
|
||||||
|
// own teamRef -> TerdutTeam.status, never chains up to TerdutServer"
|
||||||
|
// rule -- shared by TerdutEscalationRule and TerdutDeadmanSwitch, which
|
||||||
|
// both need exactly this and nothing else to call terdut-server's API.
|
||||||
|
func resolveTeamAndClient(
|
||||||
|
ctx context.Context, c client.Client, operatorNamespace, namespace string,
|
||||||
|
ref terdutv1alpha1.TerdutTeamRef, newClient func(string) *tdclient.Client,
|
||||||
|
) (*terdutv1alpha1.TerdutTeam, *tdclient.Client, *childError) {
|
||||||
|
var team terdutv1alpha1.TerdutTeam
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &team); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil, nil, &childError{
|
||||||
|
reason: terdutv1alpha1.ReasonTeamRefNotFound,
|
||||||
|
message: fmt.Sprintf("TerdutTeam %q not found in namespace %q", ref.Name, namespace),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil, &childError{reason: terdutv1alpha1.ReasonTeamRefNotFound, message: err.Error()}
|
||||||
|
}
|
||||||
|
if team.Status.CredentialsSecretRef == nil || team.Status.TeamID == 0 {
|
||||||
|
return nil, nil, &childError{
|
||||||
|
reason: terdutv1alpha1.ReasonWaitingForTeam,
|
||||||
|
message: fmt.Sprintf("TerdutTeam %q is not Ready yet", ref.Name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
teamKey, err := readOperatorSecret(ctx, c, operatorNamespace, team.Status.CredentialsSecretRef)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, &childError{reason: terdutv1alpha1.ReasonWaitingForTeam, message: err.Error()}
|
||||||
|
}
|
||||||
|
return &team, newClient(team.Status.ServerEndpoint).WithToken(teamKey), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const deadmanFinalizerName = "terdut.ryuvia.com/terdutdeadmanswitch"
|
||||||
|
|
||||||
|
// TerdutDeadmanSwitchReconciler reconciles a TerdutDeadmanSwitch object.
|
||||||
|
type TerdutDeadmanSwitchReconciler struct {
|
||||||
|
client.Client
|
||||||
|
Scheme *runtime.Scheme
|
||||||
|
|
||||||
|
OperatorNamespace string
|
||||||
|
Recorder recorder.EventRecorder
|
||||||
|
NewClient func(endpoint string) *tdclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/status,verbs=get;update;patch
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutdeadmanswitches/finalizers,verbs=update
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
||||||
|
|
||||||
|
func (r *TerdutDeadmanSwitchReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
|
log := logf.FromContext(ctx)
|
||||||
|
|
||||||
|
var sw terdutv1alpha1.TerdutDeadmanSwitch
|
||||||
|
if err := r.Get(ctx, req.NamespacedName, &sw); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newClient := r.NewClient
|
||||||
|
if newClient == nil {
|
||||||
|
newClient = tdclient.New
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sw.DeletionTimestamp.IsZero() {
|
||||||
|
return r.reconcileDeadmanDelete(ctx, &sw, newClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !controllerutil.ContainsFinalizer(&sw, deadmanFinalizerName) {
|
||||||
|
controllerutil.AddFinalizer(&sw, deadmanFinalizerName)
|
||||||
|
if err := r.Update(ctx, &sw); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return r.setDeadmanNotReady(ctx, &sw, resolveErr.reason, resolveErr.message, waitInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout, err := time.ParseDuration(sw.Spec.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("spec.timeout %q: %w", sw.Spec.Timeout, err)
|
||||||
|
}
|
||||||
|
severity := sw.Spec.Severity
|
||||||
|
if severity == "" {
|
||||||
|
severity = "critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
if sw.Status.SwitchID == 0 {
|
||||||
|
if err := r.createOrAdoptDeadmanSwitch(ctx, &sw, tc, team.Status.TeamID, timeout, severity); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
} else if err := tc.UpdateDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity); err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/deadman/switches/%d: %w", team.Status.TeamID, sw.Status.SwitchID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
|
||||||
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
|
Status: metav1.ConditionTrue,
|
||||||
|
Reason: terdutv1alpha1.ReasonChildAdopted,
|
||||||
|
Message: fmt.Sprintf("switch %d applied on team %d", sw.Status.SwitchID, team.Status.TeamID),
|
||||||
|
})
|
||||||
|
sw.Status.ObservedGeneration = sw.Generation
|
||||||
|
if err := r.Status().Update(ctx, &sw); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(&sw, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
|
||||||
|
"dead man's switch applied")
|
||||||
|
}
|
||||||
|
log.Info("TerdutDeadmanSwitch applied", "name", sw.Name, "switchID", sw.Status.SwitchID)
|
||||||
|
|
||||||
|
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createOrAdoptDeadmanSwitch implements this resource's own idempotent-
|
||||||
|
// create shape (DESIGN.md §4.4, §5): there's no unique-name constraint
|
||||||
|
// server-side to 409 on, so this lists first and matches by name (the
|
||||||
|
// server's own derived name, when spec.name is empty) rather than adopting
|
||||||
|
// after a conflict the API would never actually raise.
|
||||||
|
func (r *TerdutDeadmanSwitchReconciler) createOrAdoptDeadmanSwitch(
|
||||||
|
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, tc *tdclient.Client,
|
||||||
|
teamID int64, timeout time.Duration, severity string,
|
||||||
|
) error {
|
||||||
|
existing, err := tc.ListDeadmanSwitches(ctx, teamID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GET /api/teams/%d/deadman/switches: %w", teamID, err)
|
||||||
|
}
|
||||||
|
if sw.Spec.Name != "" {
|
||||||
|
for _, s := range existing {
|
||||||
|
if s.Name == sw.Spec.Name {
|
||||||
|
sw.Status.SwitchID = s.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := tc.CreateDeadmanSwitch(ctx, teamID, sw.Spec.Name, sw.Spec.Matcher, int64(timeout.Seconds()), severity)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("POST /api/teams/%d/deadman/switches: %w", teamID, err)
|
||||||
|
}
|
||||||
|
sw.Status.SwitchID = created.ID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TerdutDeadmanSwitchReconciler) setDeadmanNotReady(
|
||||||
|
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, reason, message string, d time.Duration,
|
||||||
|
) (ctrl.Result, error) {
|
||||||
|
meta.SetStatusCondition(&sw.Status.Conditions, metav1.Condition{
|
||||||
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
Reason: reason,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
sw.Status.ObservedGeneration = sw.Generation
|
||||||
|
if err := r.Status().Update(ctx, sw); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, reason, reason, message)
|
||||||
|
}
|
||||||
|
return ctrl.Result{RequeueAfter: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileDeadmanDelete calls the real DELETE this resource actually has
|
||||||
|
// (unlike TerdutEscalationRule) if the team is still resolvable and a
|
||||||
|
// switch was ever created, then removes the finalizer unconditionally.
|
||||||
|
func (r *TerdutDeadmanSwitchReconciler) reconcileDeadmanDelete(
|
||||||
|
ctx context.Context, sw *terdutv1alpha1.TerdutDeadmanSwitch, newClient func(string) *tdclient.Client,
|
||||||
|
) (ctrl.Result, error) {
|
||||||
|
if !controllerutil.ContainsFinalizer(sw, deadmanFinalizerName) {
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if sw.Status.SwitchID != 0 {
|
||||||
|
if team, tc, resolveErr := resolveTeamAndClient(
|
||||||
|
ctx, r.Client, r.OperatorNamespace, sw.Namespace, sw.Spec.TeamRef, newClient,
|
||||||
|
); resolveErr == nil {
|
||||||
|
if err := tc.DeleteDeadmanSwitch(ctx, team.Status.TeamID, sw.Status.SwitchID); err != nil {
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(sw, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerutil.RemoveFinalizer(sw, deadmanFinalizerName)
|
||||||
|
return ctrl.Result{}, r.Update(ctx, sw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupWithManager sets up the controller with the Manager.
|
||||||
|
func (r *TerdutDeadmanSwitchReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
|
if r.NewClient == nil {
|
||||||
|
r.NewClient = tdclient.New
|
||||||
|
}
|
||||||
|
if r.Recorder == nil {
|
||||||
|
r.Recorder = mgr.GetEventRecorder("terdutdeadmanswitch-controller")
|
||||||
|
}
|
||||||
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
|
For(&terdutv1alpha1.TerdutDeadmanSwitch{}).
|
||||||
|
Named("terdutdeadmanswitch").
|
||||||
|
Complete(r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("TerdutDeadmanSwitch Controller", func() {
|
||||||
|
const operatorNamespace = "default"
|
||||||
|
|
||||||
|
var (
|
||||||
|
reconciler *TerdutDeadmanSwitchReconciler
|
||||||
|
fake *fakeTerdutServer
|
||||||
|
fakeSrv *httptest.Server
|
||||||
|
srv *terdutv1alpha1.TerdutServer
|
||||||
|
team *terdutv1alpha1.TerdutTeam
|
||||||
|
swName string
|
||||||
|
swKey types.NamespacedName
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func(ctx SpecContext) {
|
||||||
|
fake, fakeSrv = newFakeTerdutServer()
|
||||||
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
|
||||||
|
srv = bootstrapReadyTerdutServer(ctx, operatorNamespace, uniqueName("dmserver"), fakeSrv.URL)
|
||||||
|
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("dmteam"), srv, fakeSrv.URL)
|
||||||
|
|
||||||
|
reconciler = &TerdutDeadmanSwitchReconciler{
|
||||||
|
Client: k8sClient,
|
||||||
|
Scheme: k8sClient.Scheme(),
|
||||||
|
OperatorNamespace: operatorNamespace,
|
||||||
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
||||||
|
}
|
||||||
|
swName = uniqueName("switch")
|
||||||
|
swKey = types.NamespacedName{Name: swName, Namespace: operatorNamespace}
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func(ctx SpecContext) {
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
if err := k8sClient.Get(ctx, swKey, sw); err == nil {
|
||||||
|
sw.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, sw)
|
||||||
|
_ = k8sClient.Delete(ctx, sw)
|
||||||
|
}
|
||||||
|
|
||||||
|
teamKey := types.NamespacedName{Name: team.Name, Namespace: operatorNamespace}
|
||||||
|
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
|
||||||
|
team.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, team)
|
||||||
|
_ = k8sClient.Delete(ctx, team)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
||||||
|
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
||||||
|
srv.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, srv)
|
||||||
|
_ = k8sClient.Delete(ctx, srv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createSwitch := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, name, matcher, timeout string) {
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: swName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutDeadmanSwitchSpec{
|
||||||
|
TeamRef: teamRef,
|
||||||
|
Name: name,
|
||||||
|
Matcher: matcher,
|
||||||
|
Timeout: timeout,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, sw)).To(Succeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileOnce := func(ctx context.Context) {
|
||||||
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: swKey})
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
}
|
||||||
|
|
||||||
|
readyCondition := func(ctx context.Context) metav1.Condition {
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
||||||
|
c := meta.FindStatusCondition(sw.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
|
Expect(c).NotTo(BeNil())
|
||||||
|
return *c
|
||||||
|
}
|
||||||
|
|
||||||
|
sameTeamRef := func() terdutv1alpha1.TerdutTeamRef {
|
||||||
|
return terdutv1alpha1.TerdutTeamRef{Name: team.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("the happy path", func() {
|
||||||
|
It("creates the switch server-side", func(ctx SpecContext) {
|
||||||
|
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx) // create
|
||||||
|
|
||||||
|
cond := readyCondition(ctx)
|
||||||
|
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted))
|
||||||
|
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
||||||
|
Expect(sw.Status.SwitchID).NotTo(BeZero())
|
||||||
|
|
||||||
|
created, ok := fake.switches[team.Status.TeamID][sw.Status.SwitchID]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(created.Matcher).To(Equal("alertname=Watchdog"))
|
||||||
|
Expect(created.TimeoutSeconds).To(Equal(int64(900)))
|
||||||
|
Expect(created.Severity).To(Equal("critical")) // kubebuilder default
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("list-and-match-by-name adoption", func() {
|
||||||
|
It("adopts an already-created switch instead of creating a duplicate", func(ctx SpecContext) {
|
||||||
|
// Simulates a prior, interrupted reconcile that got as far as
|
||||||
|
// POSTing the switch -- no 409 signal exists for this resource
|
||||||
|
// (DESIGN.md §4.4), so the recovery path is GET-list-and-match,
|
||||||
|
// not adopt-on-409.
|
||||||
|
fake.nextSwitchID = 1
|
||||||
|
fake.switches[team.Status.TeamID] = map[int64]tdclient.DeadmanSwitch{
|
||||||
|
1: {ID: 1, Name: "heartbeat", Matcher: "alertname=Watchdog", TimeoutSeconds: 900, Severity: "critical"},
|
||||||
|
}
|
||||||
|
|
||||||
|
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
||||||
|
Expect(sw.Status.SwitchID).To(Equal(int64(1)))
|
||||||
|
Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "should not have created a second switch")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("update-in-place on spec drift", func() {
|
||||||
|
It("PUTs the new spec rather than creating a second switch", func(ctx SpecContext) {
|
||||||
|
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
||||||
|
switchID := sw.Status.SwitchID
|
||||||
|
|
||||||
|
sw.Spec.Timeout = "30m"
|
||||||
|
Expect(k8sClient.Update(ctx, sw)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(fake.switches[team.Status.TeamID]).To(HaveLen(1), "update-in-place, not a second switch")
|
||||||
|
Expect(fake.switches[team.Status.TeamID][switchID].TimeoutSeconds).To(Equal(int64(1800)))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("waiting on the referenced TerdutTeam", func() {
|
||||||
|
It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) {
|
||||||
|
createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamRefNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports WaitingForTeam when the TerdutTeam exists but isn't Ready yet", func(ctx SpecContext) {
|
||||||
|
unreadyName := uniqueName("dmteam-unready")
|
||||||
|
unready := &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||||
|
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name},
|
||||||
|
DisplayName: "unready",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
|
||||||
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
||||||
|
|
||||||
|
createSwitch(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("deletion", func() {
|
||||||
|
It("deletes the switch server-side (the real DELETE this resource has) and removes the finalizer", func(ctx SpecContext) {
|
||||||
|
createSwitch(ctx, sameTeamRef(), "heartbeat", "alertname=Watchdog", "15m")
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
|
||||||
|
sw := &terdutv1alpha1.TerdutDeadmanSwitch{}
|
||||||
|
Expect(k8sClient.Get(ctx, swKey, sw)).To(Succeed())
|
||||||
|
switchID := sw.Status.SwitchID
|
||||||
|
|
||||||
|
Expect(k8sClient.Delete(ctx, sw)).To(Succeed())
|
||||||
|
reconcileOnce(ctx) // runs the finalizer
|
||||||
|
|
||||||
|
Expect(fake.switchDelete[switchID]).To(BeTrue())
|
||||||
|
err := k8sClient.Get(ctx, swKey, sw)
|
||||||
|
Expect(err).To(HaveOccurred(), "the TerdutDeadmanSwitch itself should be gone once the finalizer clears")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// escalationFinalizerName exists for the one undo PUT /api/teams/{teamID}/escalation
|
||||||
|
// supports, on delete: an empty policy (DESIGN.md §5's general finalizer
|
||||||
|
// rule expects a server-side counterpart to be undone, but this resource's
|
||||||
|
// API is GET/PUT-only, with no DELETE at all -- a zeroed PUT is the closest
|
||||||
|
// equivalent terdut-server itself recognizes as "no policy"
|
||||||
|
// (escalationPolicy.configured(), confirmed against source: "a policy row
|
||||||
|
// with no levels is the same as no policy").
|
||||||
|
const escalationFinalizerName = "terdut.ryuvia.com/terdutescalationrule"
|
||||||
|
|
||||||
|
// TerdutEscalationRuleReconciler reconciles a TerdutEscalationRule object.
|
||||||
|
type TerdutEscalationRuleReconciler struct {
|
||||||
|
client.Client
|
||||||
|
Scheme *runtime.Scheme
|
||||||
|
|
||||||
|
OperatorNamespace string
|
||||||
|
Recorder recorder.EventRecorder
|
||||||
|
NewClient func(endpoint string) *tdclient.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/status,verbs=get;update;patch
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutescalationrules/finalizers,verbs=update
|
||||||
|
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch
|
||||||
|
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
||||||
|
|
||||||
|
func (r *TerdutEscalationRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||||
|
log := logf.FromContext(ctx)
|
||||||
|
|
||||||
|
var rule terdutv1alpha1.TerdutEscalationRule
|
||||||
|
if err := r.Get(ctx, req.NamespacedName, &rule); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newClient := r.NewClient
|
||||||
|
if newClient == nil {
|
||||||
|
newClient = tdclient.New
|
||||||
|
}
|
||||||
|
|
||||||
|
if !rule.DeletionTimestamp.IsZero() {
|
||||||
|
return r.reconcileEscalationDelete(ctx, &rule, newClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !controllerutil.ContainsFinalizer(&rule, escalationFinalizerName) {
|
||||||
|
controllerutil.AddFinalizer(&rule, escalationFinalizerName)
|
||||||
|
if err := r.Update(ctx, &rule); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
team, tc, resolveErr := resolveTeamAndClient(ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient)
|
||||||
|
if resolveErr != nil {
|
||||||
|
return r.setEscalationNotReady(ctx, &rule, resolveErr.reason, resolveErr.message, waitInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, unknownUser, err := buildEscalationRequest(ctx, tc, rule.Spec)
|
||||||
|
if err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if unknownUser != "" {
|
||||||
|
return r.setEscalationNotReady(ctx, &rule, terdutv1alpha1.ReasonUnknownUser,
|
||||||
|
fmt.Sprintf("username %q does not resolve to any user", unknownUser), waitInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.SetEscalation(ctx, team.Status.TeamID, body); err != nil {
|
||||||
|
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/escalation: %w", team.Status.TeamID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
|
||||||
|
Type: terdutv1alpha1.ConditionReady, // "Ready" -- same name, shared across every CRD (DESIGN.md §7)
|
||||||
|
Status: metav1.ConditionTrue,
|
||||||
|
Reason: terdutv1alpha1.ReasonChildAdopted,
|
||||||
|
Message: fmt.Sprintf("escalation policy applied to team %d", team.Status.TeamID),
|
||||||
|
})
|
||||||
|
rule.Status.ObservedGeneration = rule.Generation
|
||||||
|
if err := r.Status().Update(ctx, &rule); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(&rule, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonChildAdopted, terdutv1alpha1.ReasonChildAdopted,
|
||||||
|
"escalation policy applied")
|
||||||
|
}
|
||||||
|
log.Info("TerdutEscalationRule applied", "name", rule.Name, "teamID", team.Status.TeamID)
|
||||||
|
|
||||||
|
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildEscalationRequest resolves every "user" target's username to a
|
||||||
|
// user_id (DESIGN.md §4.3) and translates spec into the wire shape
|
||||||
|
// SetEscalation sends. Returns the first unresolvable username, if any,
|
||||||
|
// distinct from a plain error: that's an expected, reportable condition
|
||||||
|
// (ReasonUnknownUser), not a reconcile failure.
|
||||||
|
func buildEscalationRequest(
|
||||||
|
ctx context.Context, tc *tdclient.Client, spec terdutv1alpha1.TerdutEscalationRuleSpec,
|
||||||
|
) (tdclient.SetEscalationRequest, string, error) {
|
||||||
|
levels := make([]tdclient.EscalationLevelRequest, len(spec.Levels))
|
||||||
|
for i, lvl := range spec.Levels {
|
||||||
|
timeout, err := time.ParseDuration(lvl.Timeout)
|
||||||
|
if err != nil {
|
||||||
|
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("spec.levels[%d].timeout %q: %w", i, lvl.Timeout, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
targets := make([]tdclient.EscalationTargetRequest, len(lvl.Targets))
|
||||||
|
for j, t := range lvl.Targets {
|
||||||
|
if t.Kind == terdutv1alpha1.EscalationTargetOncall {
|
||||||
|
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetOncall)}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user, err := tc.GetUserByUsername(ctx, t.Username)
|
||||||
|
if err != nil {
|
||||||
|
return tdclient.SetEscalationRequest{}, "", fmt.Errorf("GET /api/users (resolving %q): %w", t.Username, err)
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
return tdclient.SetEscalationRequest{}, t.Username, nil
|
||||||
|
}
|
||||||
|
targets[j] = tdclient.EscalationTargetRequest{Kind: string(terdutv1alpha1.EscalationTargetUser), UserID: &user.ID}
|
||||||
|
}
|
||||||
|
|
||||||
|
levels[i] = tdclient.EscalationLevelRequest{
|
||||||
|
Position: int64(i + 1),
|
||||||
|
TimeoutSeconds: int64(timeout.Seconds()),
|
||||||
|
Targets: targets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tdclient.SetEscalationRequest{
|
||||||
|
RepeatCount: spec.RepeatCount,
|
||||||
|
FallbackTopic: spec.FallbackTopic,
|
||||||
|
Levels: levels,
|
||||||
|
}, "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TerdutEscalationRuleReconciler) setEscalationNotReady(
|
||||||
|
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, reason, message string, d time.Duration,
|
||||||
|
) (ctrl.Result, error) {
|
||||||
|
meta.SetStatusCondition(&rule.Status.Conditions, metav1.Condition{
|
||||||
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
|
Status: metav1.ConditionFalse,
|
||||||
|
Reason: reason,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
rule.Status.ObservedGeneration = rule.Generation
|
||||||
|
if err := r.Status().Update(ctx, rule); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, reason, reason, message)
|
||||||
|
}
|
||||||
|
return ctrl.Result{RequeueAfter: d}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileEscalationDelete PUTs an empty policy (this resource's only
|
||||||
|
// available "undo", per this file's own const comment) if the team is
|
||||||
|
// still resolvable, then removes the finalizer unconditionally -- same
|
||||||
|
// "the parent's probably going away too" reasoning TerdutTeam's own delete
|
||||||
|
// path uses for a TerdutServer that's gone.
|
||||||
|
func (r *TerdutEscalationRuleReconciler) reconcileEscalationDelete(
|
||||||
|
ctx context.Context, rule *terdutv1alpha1.TerdutEscalationRule, newClient func(string) *tdclient.Client,
|
||||||
|
) (ctrl.Result, error) {
|
||||||
|
if !controllerutil.ContainsFinalizer(rule, escalationFinalizerName) {
|
||||||
|
return ctrl.Result{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if team, tc, resolveErr := resolveTeamAndClient(
|
||||||
|
ctx, r.Client, r.OperatorNamespace, rule.Namespace, rule.Spec.TeamRef, newClient,
|
||||||
|
); resolveErr == nil {
|
||||||
|
if err := tc.SetEscalation(ctx, team.Status.TeamID, tdclient.SetEscalationRequest{Levels: []tdclient.EscalationLevelRequest{}}); err != nil {
|
||||||
|
if r.Recorder != nil {
|
||||||
|
r.Recorder.Eventf(rule, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
||||||
|
}
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
controllerutil.RemoveFinalizer(rule, escalationFinalizerName)
|
||||||
|
return ctrl.Result{}, r.Update(ctx, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetupWithManager sets up the controller with the Manager.
|
||||||
|
func (r *TerdutEscalationRuleReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||||
|
if r.NewClient == nil {
|
||||||
|
r.NewClient = tdclient.New
|
||||||
|
}
|
||||||
|
if r.Recorder == nil {
|
||||||
|
r.Recorder = mgr.GetEventRecorder("terdutescalationrule-controller")
|
||||||
|
}
|
||||||
|
return ctrl.NewControllerManagedBy(mgr).
|
||||||
|
For(&terdutv1alpha1.TerdutEscalationRule{}).
|
||||||
|
Named("terdutescalationrule").
|
||||||
|
Complete(r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http/httptest"
|
||||||
|
|
||||||
|
. "github.com/onsi/ginkgo/v2"
|
||||||
|
. "github.com/onsi/gomega"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ = Describe("TerdutEscalationRule Controller", func() {
|
||||||
|
const operatorNamespace = "default"
|
||||||
|
|
||||||
|
var (
|
||||||
|
reconciler *TerdutEscalationRuleReconciler
|
||||||
|
fake *fakeTerdutServer
|
||||||
|
fakeSrv *httptest.Server
|
||||||
|
srv *terdutv1alpha1.TerdutServer
|
||||||
|
team *terdutv1alpha1.TerdutTeam
|
||||||
|
ruleName string
|
||||||
|
ruleKey types.NamespacedName
|
||||||
|
)
|
||||||
|
|
||||||
|
BeforeEach(func(ctx SpecContext) {
|
||||||
|
fake, fakeSrv = newFakeTerdutServer()
|
||||||
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
|
||||||
|
srv = bootstrapReadyTerdutServer(ctx, operatorNamespace, uniqueName("erserver"), fakeSrv.URL)
|
||||||
|
team = readyTerdutTeam(ctx, operatorNamespace, uniqueName("erteam"), srv, fakeSrv.URL)
|
||||||
|
|
||||||
|
reconciler = &TerdutEscalationRuleReconciler{
|
||||||
|
Client: k8sClient,
|
||||||
|
Scheme: k8sClient.Scheme(),
|
||||||
|
OperatorNamespace: operatorNamespace,
|
||||||
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
||||||
|
}
|
||||||
|
ruleName = uniqueName("escalation")
|
||||||
|
ruleKey = types.NamespacedName{Name: ruleName, Namespace: operatorNamespace}
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterEach(func(ctx SpecContext) {
|
||||||
|
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
||||||
|
if err := k8sClient.Get(ctx, ruleKey, rule); err == nil {
|
||||||
|
rule.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, rule)
|
||||||
|
_ = k8sClient.Delete(ctx, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// team/srv carry their own finalizers from readyTerdutTeam/
|
||||||
|
// bootstrapReadyTerdutServer -- clear them directly the same way
|
||||||
|
// terdutteam_controller_test.go's own AfterEach does, rather than
|
||||||
|
// relying on either reconciler to ever run again here.
|
||||||
|
teamKey := types.NamespacedName{Name: team.Name, Namespace: operatorNamespace}
|
||||||
|
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
|
||||||
|
team.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, team)
|
||||||
|
_ = k8sClient.Delete(ctx, team)
|
||||||
|
}
|
||||||
|
|
||||||
|
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
||||||
|
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
||||||
|
srv.Finalizers = nil
|
||||||
|
_ = k8sClient.Update(ctx, srv)
|
||||||
|
_ = k8sClient.Delete(ctx, srv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
createRule := func(ctx context.Context, teamRef terdutv1alpha1.TerdutTeamRef, levels []terdutv1alpha1.EscalationLevel) {
|
||||||
|
rule := &terdutv1alpha1.TerdutEscalationRule{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: ruleName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutEscalationRuleSpec{
|
||||||
|
TeamRef: teamRef,
|
||||||
|
Levels: levels,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, rule)).To(Succeed())
|
||||||
|
}
|
||||||
|
|
||||||
|
reconcileOnce := func(ctx context.Context) {
|
||||||
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: ruleKey})
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
}
|
||||||
|
|
||||||
|
readyCondition := func(ctx context.Context) metav1.Condition {
|
||||||
|
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
||||||
|
Expect(k8sClient.Get(ctx, ruleKey, rule)).To(Succeed())
|
||||||
|
c := meta.FindStatusCondition(rule.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
|
Expect(c).NotTo(BeNil())
|
||||||
|
return *c
|
||||||
|
}
|
||||||
|
|
||||||
|
sameTeamRef := func() terdutv1alpha1.TerdutTeamRef {
|
||||||
|
return terdutv1alpha1.TerdutTeamRef{Name: team.Name}
|
||||||
|
}
|
||||||
|
|
||||||
|
Describe("the happy path", func() {
|
||||||
|
It("resolves usernames and applies the escalation policy", func(ctx SpecContext) {
|
||||||
|
fake.seedUser("alice")
|
||||||
|
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
||||||
|
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"},
|
||||||
|
}},
|
||||||
|
{Timeout: "10m", Targets: []terdutv1alpha1.EscalationTarget{
|
||||||
|
{Kind: terdutv1alpha1.EscalationTargetOncall},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx) // resolve + apply
|
||||||
|
|
||||||
|
cond := readyCondition(ctx)
|
||||||
|
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonChildAdopted))
|
||||||
|
|
||||||
|
applied, ok := fake.escalation[team.Status.TeamID]
|
||||||
|
Expect(ok).To(BeTrue())
|
||||||
|
Expect(applied.Levels).To(HaveLen(2))
|
||||||
|
Expect(applied.Levels[0].Targets[0].Kind).To(Equal("user"))
|
||||||
|
Expect(applied.Levels[0].Targets[0].UserID).NotTo(BeNil())
|
||||||
|
Expect(*applied.Levels[0].Targets[0].UserID).To(Equal(fake.users["alice"]))
|
||||||
|
Expect(applied.Levels[1].Targets[0].Kind).To(Equal("oncall"))
|
||||||
|
Expect(applied.Levels[1].Targets[0].UserID).To(BeNil())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("an unresolvable username", func() {
|
||||||
|
It("reports UnknownUser and never calls PUT /api/teams/{id}/escalation", func(ctx SpecContext) {
|
||||||
|
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
||||||
|
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "ghost"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
cond := readyCondition(ctx)
|
||||||
|
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
|
||||||
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonUnknownUser))
|
||||||
|
_, applied := fake.escalation[team.Status.TeamID]
|
||||||
|
Expect(applied).To(BeFalse())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("waiting on the referenced TerdutTeam", func() {
|
||||||
|
It("reports TeamRefNotFound when the TerdutTeam doesn't exist", func(ctx SpecContext) {
|
||||||
|
createRule(ctx, terdutv1alpha1.TerdutTeamRef{Name: testRefNotFoundName}, []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
|
||||||
|
})
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonTeamRefNotFound))
|
||||||
|
})
|
||||||
|
|
||||||
|
It("reports WaitingForTeam when the TerdutTeam exists but isn't Ready yet", func(ctx SpecContext) {
|
||||||
|
unreadyName := uniqueName("erteam-unready")
|
||||||
|
unready := &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||||
|
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name},
|
||||||
|
DisplayName: "unready",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
|
||||||
|
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
||||||
|
|
||||||
|
createRule(ctx, terdutv1alpha1.TerdutTeamRef{Name: unreadyName}, []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
|
||||||
|
})
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForTeam))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("deletion", func() {
|
||||||
|
It("PUTs an empty policy (this resource's only available undo) and removes the finalizer", func(ctx SpecContext) {
|
||||||
|
fake.seedUser("alice")
|
||||||
|
createRule(ctx, sameTeamRef(), []terdutv1alpha1.EscalationLevel{
|
||||||
|
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{
|
||||||
|
{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||||
|
Expect(fake.escalation[team.Status.TeamID].Levels).To(HaveLen(1))
|
||||||
|
|
||||||
|
rule := &terdutv1alpha1.TerdutEscalationRule{}
|
||||||
|
Expect(k8sClient.Get(ctx, ruleKey, rule)).To(Succeed())
|
||||||
|
Expect(k8sClient.Delete(ctx, rule)).To(Succeed())
|
||||||
|
reconcileOnce(ctx) // runs the finalizer
|
||||||
|
|
||||||
|
Expect(fake.escalation[team.Status.TeamID].Levels).To(BeEmpty())
|
||||||
|
err := k8sClient.Get(ctx, ruleKey, rule)
|
||||||
|
Expect(err).To(HaveOccurred(), "the TerdutEscalationRule itself should be gone once the finalizer clears")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
@@ -51,20 +53,54 @@ type fakeTerdutServer struct {
|
|||||||
teamNames map[int64]string // id -> current name (renames update this)
|
teamNames map[int64]string // id -> current name (renames update this)
|
||||||
teamOIDC map[int64][2]string
|
teamOIDC map[int64][2]string
|
||||||
teamDelete map[int64]bool // id -> true once DELETEd, for 404-on-redelete
|
teamDelete map[int64]bool // id -> true once DELETEd, for 404-on-redelete
|
||||||
|
|
||||||
|
// users backs GET /api/users for TerdutEscalationRule's username
|
||||||
|
// resolution (DESIGN.md §4.3) -- a fixed, pre-seeded directory, since
|
||||||
|
// nothing in this controller's own flow ever creates a user.
|
||||||
|
users map[string]int64 // username -> id
|
||||||
|
|
||||||
|
// escalation backs PUT /api/teams/{id}/escalation -- an upsert
|
||||||
|
// server-side (confirmed against source), so this is just "the last
|
||||||
|
// body PUT for this team", keyed by teamID, with no separate create
|
||||||
|
// step to model.
|
||||||
|
escalation map[int64]tdclient.SetEscalationRequest
|
||||||
|
|
||||||
|
// switches/nextSwitchID/switchDelete back the dead man's switch
|
||||||
|
// endpoints -- no unique-name constraint server-side (DESIGN.md §4.4),
|
||||||
|
// so switches is keyed by id, not name, same as the real API's own
|
||||||
|
// GET-list-and-match-by-name idempotent-create shape requires.
|
||||||
|
nextSwitchID int64
|
||||||
|
switches map[int64]map[int64]tdclient.DeadmanSwitch // teamID -> switchID -> switch
|
||||||
|
switchDelete map[int64]bool // switchID -> true once DELETEd, for 404-on-redelete
|
||||||
}
|
}
|
||||||
|
|
||||||
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||||
f := &fakeTerdutServer{
|
f := &fakeTerdutServer{
|
||||||
accounts: map[string]int64{},
|
accounts: map[string]int64{},
|
||||||
keyMints: map[int64]int{},
|
keyMints: map[int64]int{},
|
||||||
teams: map[string]int64{},
|
teams: map[string]int64{},
|
||||||
teamNames: map[int64]string{},
|
teamNames: map[int64]string{},
|
||||||
teamOIDC: map[int64][2]string{},
|
teamOIDC: map[int64][2]string{},
|
||||||
teamDelete: map[int64]bool{},
|
teamDelete: map[int64]bool{},
|
||||||
|
users: map[string]int64{},
|
||||||
|
escalation: map[int64]tdclient.SetEscalationRequest{},
|
||||||
|
switches: map[int64]map[int64]tdclient.DeadmanSwitch{},
|
||||||
|
switchDelete: map[int64]bool{},
|
||||||
}
|
}
|
||||||
return f, httptest.NewServer(f)
|
return f, httptest.NewServer(f)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// seedUser registers a username the fake GET /api/users will return --
|
||||||
|
// called from test setup, before the controller under test ever runs.
|
||||||
|
// Callers read the assigned id back from f.users themselves, so this has
|
||||||
|
// nothing left to return.
|
||||||
|
func (f *fakeTerdutServer) seedUser(username string) {
|
||||||
|
f.mu.Lock()
|
||||||
|
defer f.mu.Unlock()
|
||||||
|
f.nextID++
|
||||||
|
f.users[username] = f.nextID
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
defer f.mu.Unlock()
|
defer f.mu.Unlock()
|
||||||
@@ -137,6 +173,16 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, []tdclient.Team{{ID: id, Name: f.teamNames[id]}})
|
writeJSON(w, http.StatusOK, []tdclient.Team{{ID: id, Name: f.teamNames[id]}})
|
||||||
|
|
||||||
|
case r.URL.Path == "/api/users" && r.Method == http.MethodGet:
|
||||||
|
// No query filter -- GetUserByUsername fetches the whole list and
|
||||||
|
// matches client-side (confirmed against source: no server-side
|
||||||
|
// filter either), so the fake does the same.
|
||||||
|
users := make([]tdclient.User, 0, len(f.users))
|
||||||
|
for name, id := range f.users {
|
||||||
|
users = append(users, tdclient.User{ID: id, Username: name})
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, users)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
|
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
|
||||||
f.keyMints[id]++
|
f.keyMints[id]++
|
||||||
@@ -146,34 +192,21 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if id, ok := parseTeamPath(r.URL.Path); ok {
|
if id, rest, ok := parseTeamSubPath(r.URL.Path); ok {
|
||||||
f.handleTeamByID(w, r, id)
|
f.handleTeamSubPath(w, r, id, rest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTeamByID answers PUT /api/teams/{id}, PUT /api/teams/{id}/oidc-groups
|
// handleTeamSubPath answers everything under /api/teams/{id}: PUT (rename),
|
||||||
// and DELETE /api/teams/{id}.
|
// DELETE, PUT .../oidc-groups, PUT .../escalation, and the dead man's
|
||||||
func (f *fakeTerdutServer) handleTeamByID(w http.ResponseWriter, r *http.Request, id int64) {
|
// switch collection/item endpoints. rest is whatever parseTeamSubPath found
|
||||||
oidcSuffix := fmt.Sprintf("/api/teams/%d/oidc-groups", id)
|
// after "/api/teams/{id}" -- "" for the bare resource.
|
||||||
|
func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
|
||||||
switch {
|
switch {
|
||||||
case r.URL.Path == oidcSuffix && r.Method == http.MethodPut:
|
case rest == "" && r.Method == http.MethodPut:
|
||||||
var req struct {
|
|
||||||
MemberGroup string `json:"member_group"`
|
|
||||||
OwnerGroup string `json:"owner_group"`
|
|
||||||
}
|
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
if _, exists := f.teamNames[id]; !exists {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
|
|
||||||
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodPut:
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
@@ -188,7 +221,7 @@ func (f *fakeTerdutServer) handleTeamByID(w http.ResponseWriter, r *http.Request
|
|||||||
f.teams[req.Name] = id
|
f.teams[req.Name] = id
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodDelete:
|
case rest == "" && r.Method == http.MethodDelete:
|
||||||
name, exists := f.teamNames[id]
|
name, exists := f.teamNames[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
@@ -199,23 +232,132 @@ func (f *fakeTerdutServer) handleTeamByID(w http.ResponseWriter, r *http.Request
|
|||||||
f.teamDelete[id] = true
|
f.teamDelete[id] = true
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "/oidc-groups" && r.Method == http.MethodPut:
|
||||||
|
var req struct {
|
||||||
|
MemberGroup string `json:"member_group"`
|
||||||
|
OwnerGroup string `json:"owner_group"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
if _, exists := f.teamNames[id]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "/escalation" && r.Method == http.MethodPut:
|
||||||
|
var req tdclient.SetEscalationRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
f.escalation[id] = req
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case rest == "/deadman/switches" && r.Method == http.MethodGet:
|
||||||
|
existing := f.switches[id]
|
||||||
|
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
|
||||||
|
for _, s := range existing {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
|
||||||
|
case rest == "/deadman/switches" && r.Method == http.MethodPost:
|
||||||
|
var req deadmanSwitchFakeRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
f.nextSwitchID++
|
||||||
|
switchID := f.nextSwitchID
|
||||||
|
name := req.Name
|
||||||
|
if name == "" {
|
||||||
|
name = "derived-" + req.Matcher
|
||||||
|
}
|
||||||
|
sw := tdclient.DeadmanSwitch{
|
||||||
|
ID: switchID, Name: name, Matcher: req.Matcher,
|
||||||
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
|
}
|
||||||
|
if f.switches[id] == nil {
|
||||||
|
f.switches[id] = map[int64]tdclient.DeadmanSwitch{}
|
||||||
|
}
|
||||||
|
f.switches[id][switchID] = sw
|
||||||
|
writeJSON(w, http.StatusCreated, sw)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodPut:
|
||||||
|
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.switches[id][switchID]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req deadmanSwitchFakeRequest
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
name := req.Name
|
||||||
|
if name == "" {
|
||||||
|
name = f.switches[id][switchID].Name
|
||||||
|
}
|
||||||
|
f.switches[id][switchID] = tdclient.DeadmanSwitch{
|
||||||
|
ID: switchID, Name: name, Matcher: req.Matcher,
|
||||||
|
TimeoutSeconds: req.TimeoutSeconds, Severity: req.Severity,
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
|
case strings.HasPrefix(rest, "/deadman/switches/") && r.Method == http.MethodDelete:
|
||||||
|
switchID, ok := parseTrailingID(rest, "/deadman/switches/")
|
||||||
|
if !ok {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, exists := f.switches[id][switchID]; !exists {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(f.switches[id], switchID)
|
||||||
|
f.switchDelete[switchID] = true
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseTeamPath extracts the numeric id from "/api/teams/{id}" or
|
// deadmanSwitchFakeRequest mirrors tdclient's own (unexported)
|
||||||
// "/api/teams/{id}/oidc-groups" -- anything with more or fewer segments
|
// deadmanSwitchRequest -- the fake needs its own copy to decode the same
|
||||||
// doesn't match (handleTeamByID's own switch sorts out which of the two).
|
// wire shape without reaching across package boundaries for an internal type.
|
||||||
func parseTeamPath(path string) (id int64, ok bool) {
|
type deadmanSwitchFakeRequest struct {
|
||||||
var parsedID int64
|
Name string `json:"name,omitempty"`
|
||||||
if n, err := fmt.Sscanf(path, "/api/teams/%d/oidc-groups", &parsedID); err == nil && n == 1 {
|
Matcher string `json:"matcher"`
|
||||||
return parsedID, true
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTeamSubPath splits "/api/teams/{id}" from anything after it --
|
||||||
|
// "" for an exact match, "/oidc-groups", "/escalation", "/deadman/switches"
|
||||||
|
// or "/deadman/switches/{switchID}" otherwise. Doesn't itself validate the
|
||||||
|
// suffix; handleTeamSubPath's own switch does that.
|
||||||
|
func parseTeamSubPath(path string) (id int64, rest string, ok bool) {
|
||||||
|
const prefix = "/api/teams/"
|
||||||
|
if !strings.HasPrefix(path, prefix) {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
if n, err := fmt.Sscanf(path, "/api/teams/%d", &parsedID); err == nil && n == 1 {
|
trimmed := path[len(prefix):]
|
||||||
return parsedID, true
|
parts := strings.SplitN(trimmed, "/", 2)
|
||||||
|
parsedID, err := strconv.ParseInt(parts[0], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, "", false
|
||||||
}
|
}
|
||||||
return 0, false
|
if len(parts) == 1 {
|
||||||
|
return parsedID, "", true
|
||||||
|
}
|
||||||
|
return parsedID, "/" + parts[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseTrailingID parses the numeric id after prefix within rest, e.g.
|
||||||
|
// parseTrailingID("/deadman/switches/7", "/deadman/switches/") -> 7, true.
|
||||||
|
func parseTrailingID(rest, prefix string) (id int64, ok bool) {
|
||||||
|
parsedID, err := strconv.ParseInt(strings.TrimPrefix(rest, prefix), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return parsedID, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseKeysPath(path string) (id int64, mintName string, ok bool) {
|
func parseKeysPath(path string) (id int64, mintName string, ok bool) {
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
instanceClient := newClient(serviceURL(srv)).WithToken(instanceKey)
|
endpoint := serviceURL(srv)
|
||||||
|
team.Status.ServerEndpoint = endpoint
|
||||||
|
instanceClient := newClient(endpoint).WithToken(instanceKey)
|
||||||
|
|
||||||
if team.Status.TeamID == 0 {
|
if team.Status.TeamID == 0 {
|
||||||
if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil {
|
if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil {
|
||||||
@@ -113,7 +115,7 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
teamClient := newClient(serviceURL(srv)).WithToken(teamKey)
|
teamClient := newClient(endpoint).WithToken(teamKey)
|
||||||
|
|
||||||
// Owner-gated on terdut-server, so this always runs with the
|
// Owner-gated on terdut-server, so this always runs with the
|
||||||
// team-scoped credential just minted above, never the instance-scoped
|
// team-scoped credential just minted above, never the instance-scoped
|
||||||
@@ -131,7 +133,7 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionTeamReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
Status: metav1.ConditionTrue,
|
Status: metav1.ConditionTrue,
|
||||||
Reason: terdutv1alpha1.ReasonTeamAdopted,
|
Reason: terdutv1alpha1.ReasonTeamAdopted,
|
||||||
Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name),
|
Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name),
|
||||||
@@ -208,7 +210,7 @@ func (r *TerdutTeamReconciler) setTeamNotReady(
|
|||||||
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration,
|
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration,
|
||||||
) (ctrl.Result, error) {
|
) (ctrl.Result, error) {
|
||||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionTeamReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
Status: metav1.ConditionFalse,
|
Status: metav1.ConditionFalse,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
Message: message,
|
Message: message,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
readyCondition := func(ctx context.Context) metav1.Condition {
|
readyCondition := func(ctx context.Context) metav1.Condition {
|
||||||
team := &terdutv1alpha1.TerdutTeam{}
|
team := &terdutv1alpha1.TerdutTeam{}
|
||||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||||
c := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
c := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
Expect(c).NotTo(BeNil())
|
Expect(c).NotTo(BeNil())
|
||||||
return *c
|
return *c
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
|
|
||||||
Describe("waiting on the referenced TerdutServer", func() {
|
Describe("waiting on the referenced TerdutServer", func() {
|
||||||
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
|
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: "does-not-exist"})
|
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: testRefNotFoundName})
|
||||||
reconcileOnce(ctx) // finalizer
|
reconcileOnce(ctx) // finalizer
|
||||||
reconcileOnce(ctx)
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
||||||
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonRefNotPermitted))
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonRefNotPermitted))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ var _ = Describe("TerdutTeam Controller", func() {
|
|||||||
Expect(err).NotTo(HaveOccurred())
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
||||||
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionReady)
|
||||||
// Past the gate: Adopted (the fake server has no reason to
|
// Past the gate: Adopted (the fake server has no reason to
|
||||||
// reject this), definitely not RefNotPermitted.
|
// reject this), definitely not RefNotPermitted.
|
||||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
||||||
|
|||||||
@@ -23,6 +23,13 @@ const (
|
|||||||
testImageRepo = "example.invalid/terdut-server"
|
testImageRepo = "example.invalid/terdut-server"
|
||||||
testImageTag = "test"
|
testImageTag = "test"
|
||||||
testDSN = "postgres://terdut@test-postgres:5432/terdut?sslmode=require"
|
testDSN = "postgres://terdut@test-postgres:5432/terdut?sslmode=require"
|
||||||
|
|
||||||
|
// testRefNotFoundName is a name no TerdutServer/TerdutTeam ever gets
|
||||||
|
// created with -- shared by every "...RefNotFound" test across
|
||||||
|
// terdutteam_controller_test.go, terdutescalationrule_controller_test.go
|
||||||
|
// and terdutdeadmanswitch_controller_test.go, so goconst doesn't flag
|
||||||
|
// three independent copies of the same literal.
|
||||||
|
testRefNotFoundName = "does-not-exist"
|
||||||
)
|
)
|
||||||
|
|
||||||
// bootstrapReadyTerdutServer creates a TerdutServer with a bring-your-own
|
// bootstrapReadyTerdutServer creates a TerdutServer with a bring-your-own
|
||||||
@@ -70,6 +77,41 @@ func bootstrapReadyTerdutServer(ctx context.Context, namespace, name, fakeURL st
|
|||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readyTerdutTeam creates a TerdutTeam under srv (an already-Ready
|
||||||
|
// TerdutServer, e.g. from bootstrapReadyTerdutServer) and drives it to
|
||||||
|
// Ready against fakeURL -- shared by TerdutEscalationRule's and
|
||||||
|
// TerdutDeadmanSwitch's own tests, which both just need a resolvable
|
||||||
|
// teamRef (DESIGN.md §5), not TerdutTeam's own behavior.
|
||||||
|
func readyTerdutTeam(ctx context.Context, namespace, name string, srv *terdutv1alpha1.TerdutServer, fakeURL string) *terdutv1alpha1.TerdutTeam {
|
||||||
|
GinkgoHelper()
|
||||||
|
|
||||||
|
reconciler := &TerdutTeamReconciler{
|
||||||
|
Client: k8sClient,
|
||||||
|
Scheme: k8sClient.Scheme(),
|
||||||
|
OperatorNamespace: namespace,
|
||||||
|
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
|
||||||
|
}
|
||||||
|
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
||||||
|
|
||||||
|
team := &terdutv1alpha1.TerdutTeam{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
||||||
|
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||||
|
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name},
|
||||||
|
DisplayName: name,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
Expect(k8sClient.Create(ctx, team)).To(Succeed())
|
||||||
|
|
||||||
|
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // create+mint+apply
|
||||||
|
Expect(err).NotTo(HaveOccurred())
|
||||||
|
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, team)).To(Succeed())
|
||||||
|
Expect(team.Status.CredentialsSecretRef).NotTo(BeNil(), "test setup: TerdutTeam %s/%s did not reach Ready", namespace, name)
|
||||||
|
return team
|
||||||
|
}
|
||||||
|
|
||||||
// uniqueNameCounter backs uniqueName. GinkgoRandomSeed()/GinkgoParallelProcess()
|
// uniqueNameCounter backs uniqueName. GinkgoRandomSeed()/GinkgoParallelProcess()
|
||||||
// are constants for the whole suite run, not per-spec -- an earlier version
|
// are constants for the whole suite run, not per-spec -- an earlier version
|
||||||
// of this helper used them and collided across every spec that called it
|
// of this helper used them and collided across every spec that called it
|
||||||
|
|||||||
@@ -357,3 +357,142 @@ func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGrou
|
|||||||
}
|
}
|
||||||
return c.do(req, nil)
|
return c.do(req, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// User mirrors terdut-server's models.User, minus fields this client never
|
||||||
|
// reads.
|
||||||
|
type User struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername calls GET /api/users and finds the one matching exactly
|
||||||
|
// -- confirmed open to any authenticated caller, not gated by team
|
||||||
|
// membership or admin (internal/api/router.go's own comment: "readable by
|
||||||
|
// anyone signed in"), so the team-scoped credential a TerdutEscalationRule's
|
||||||
|
// controller already holds is enough. There is no server-side filter, so
|
||||||
|
// this always fetches the whole list; terdut-server's own query has no
|
||||||
|
// pagination either (confirmed against source), so this matches what the
|
||||||
|
// server itself considers an acceptable cost. Returns nil, nil on no match.
|
||||||
|
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, "/api/users", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var users []User
|
||||||
|
if err := c.do(req, &users); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, u := range users {
|
||||||
|
if u.Username == username {
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
|
||||||
|
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
|
||||||
|
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
|
||||||
|
// (escalationView), which this client never needs to decode since the
|
||||||
|
// controller always computes its own desired state fresh from spec.
|
||||||
|
type EscalationTargetRequest struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
UserID *int64 `json:"user_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type EscalationLevelRequest struct {
|
||||||
|
Position int64 `json:"position"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Targets []EscalationTargetRequest `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SetEscalationRequest struct {
|
||||||
|
RepeatCount int64 `json:"repeat_count"`
|
||||||
|
FallbackTopic string `json:"fallback_topic"`
|
||||||
|
Levels []EscalationLevelRequest `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEscalation calls PUT /api/teams/{teamID}/escalation -- an upsert
|
||||||
|
// server-side (confirmed against source: `INSERT ... ON CONFLICT (team_id)
|
||||||
|
// DO UPDATE`), so there is no separate create step for this resource at
|
||||||
|
// all, unlike Team or the dead man's switch.
|
||||||
|
func (c *Client) SetEscalation(ctx context.Context, teamID int64, body SetEscalationRequest) error {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPut, fmt.Sprintf("/api/teams/%d/escalation", teamID), body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeadmanSwitch mirrors terdut-server's deadmanSwitchStatus
|
||||||
|
// (internal/api/deadman.go), minus fields this client never reads.
|
||||||
|
type DeadmanSwitch struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDeadmanSwitches calls GET /api/teams/{teamID}/deadman/switches. There
|
||||||
|
// is no unique-name constraint on this resource server-side (confirmed
|
||||||
|
// against source), so this is the idempotent-create lookup for it --
|
||||||
|
// GET-list-and-match-by-name, not adopt-on-409.
|
||||||
|
func (c *Client) ListDeadmanSwitches(ctx context.Context, teamID int64) ([]DeadmanSwitch, error) {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodGet, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var switches []DeadmanSwitch
|
||||||
|
if err := c.do(req, &switches); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return switches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadmanSwitchRequest mirrors terdut-server's own deadmanSwitchRequest
|
||||||
|
// (internal/api/teams.go) -- the same body shape for both create and
|
||||||
|
// update.
|
||||||
|
type deadmanSwitchRequest struct {
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Matcher string `json:"matcher"`
|
||||||
|
TimeoutSeconds int64 `json:"timeout_seconds"`
|
||||||
|
Severity string `json:"severity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDeadmanSwitch calls POST /api/teams/{teamID}/deadman/switches.
|
||||||
|
func (c *Client) CreateDeadmanSwitch(ctx context.Context, teamID int64, name, matcher string, timeoutSeconds int64, severity string) (*DeadmanSwitch, error) {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/deadman/switches", teamID),
|
||||||
|
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var sw DeadmanSwitch
|
||||||
|
if err := c.do(req, &sw); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &sw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDeadmanSwitch calls PUT /api/teams/{teamID}/deadman/switches/{switchID}
|
||||||
|
// -- real update-in-place, added in terdut-server v0.33.0 specifically for
|
||||||
|
// this controller (that handler's own doc comment names terdut-operator).
|
||||||
|
func (c *Client) UpdateDeadmanSwitch(ctx context.Context, teamID, switchID int64, name, matcher string, timeoutSeconds int64, severity string) error {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodPut,
|
||||||
|
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID),
|
||||||
|
deadmanSwitchRequest{Name: name, Matcher: matcher, TimeoutSeconds: timeoutSeconds, Severity: severity})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDeadmanSwitch calls DELETE /api/teams/{teamID}/deadman/switches/{switchID}.
|
||||||
|
func (c *Client) DeleteDeadmanSwitch(ctx context.Context, teamID, switchID int64) error {
|
||||||
|
req, err := c.newRequest(ctx, http.MethodDelete,
|
||||||
|
fmt.Sprintf("/api/teams/%d/deadman/switches/%d", teamID, switchID), nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.do(req, nil)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user