Stage 4: TerdutAlertSource
CI / test (push) Successful in 1m34s

Covers webhook Secret generation/ownership (DESIGN.md §4.5, §7), the
WebhookSecretLost fail-closed condition, and the kind-change
delete-and-recreate rotation path.

Idempotent-create here is deliberately neither adopt-on-409
(Team/service-account) nor list-and-match-by-name (TerdutDeadmanSwitch):
terdut-server shows the webhook key exactly once, at creation, and never
again, so no server-side lookup could ever recover it after a crash.
Instead the generated webhook Secret itself -- written immediately after
the POST, before status is ever touched -- is this CR's only durable
record that a create already succeeded; found with status.integrationID
still unset on a later reconcile, it's read back directly rather than
POSTing a second, orphaned integration. Found missing with
status.integrationID *set* instead, that's the already-designed
WebhookSecretLost case: fail closed, not self-healed, since the key is
genuinely gone and recreating it would rotate a live webhook URL with no
spec change to explain why.

Renaming (PATCH) never touches the key, so it's applied unconditionally
every reconcile, same as the escalation policy's whole-policy PUT. A
spec.kind change is the one case with no in-place update verb at all:
DELETE the old integration, delete the stale webhook Secret, then run the
same create path fresh -- fires a Warning event since this breaks whatever
still sends to the old URL.

Also: fakeTerdutServer grows POST/PATCH/DELETE .../integrations routes
behind a new handleIntegrationSubPath, split out of handleTeamSubPath to
stay under gocyclo's threshold; three goconst-flagged test literals
("does-not-exist", "unready") and one unparam-flagged test helper
parameter (bootstrapReadyTerdutServer's always-"default" namespace) get
shared/removed now that a fourth same-shaped caller made the repetition
concrete enough for the linter to flag.

DESIGN.md §13 gains one honest gap found while grounding this stage, not
introduced by it: no child CRD specially detects a mid-life teamRef
change; all three always resolve spec.teamRef fresh and trust the
already-stored server-side id remains valid there.

make fmt lint test build all clean; internal/controller envtest coverage
holds at 71.6%.
This commit is contained in:
Niklas Ye
2026-10-01 14:13:19 +02:00
parent b0d50e305a
commit 048f4448c4
23 changed files with 1395 additions and 9 deletions
+139
View File
@@ -0,0 +1,139 @@
package v1alpha1
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// LocalSecretRef names a Secret in this CR's own namespace holding more than
// one data key -- unlike SecretKeyRef (terdutserver_types.go), there's no
// single key to name here: a consumer needs both "url" and "key"
// (DESIGN.md §4.5).
type LocalSecretRef struct {
// name is the Secret's name.
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// TerdutAlertSourceSpec defines the desired state of TerdutAlertSource.
//
// Stays same-namespace as its TerdutTeam (§13: cross-namespace teamRef on
// the child CRDs is deferred, same as TerdutEscalationRule/TerdutDeadmanSwitch).
type TerdutAlertSourceSpec struct {
// +required
TeamRef TerdutTeamRef `json:"teamRef"`
// kind is the alert source type. Only "alertmanager" is supported
// today, mirroring terdut-server's own CHECK constraint on
// integrations.kind (internal/db/migrations/003_teams.sql) --
// confirmed against source, not assumed. Changing it after the
// integration already exists rotates the webhook key (DESIGN.md §5's
// reconciliation table): the old one is deleted and a fresh one
// created, which breaks whatever sends to the old URL until the new
// Secret is picked up.
// +kubebuilder:validation:Enum=alertmanager
// +kubebuilder:default=alertmanager
// +optional
Kind string `json:"kind,omitempty"`
// name is this source's own display name server-side -- distinct from
// this object's own metadata.name. POST
// /api/teams/{teamID}/integrations {"name": ...} at creation, and what
// PATCH renames thereafter; renaming never rotates the webhook key.
// +required
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// Condition/event reasons specific to TerdutAlertSource. TeamRefNotFound,
// WaitingForTeam and ChildAdopted (terdutescalationrule_types.go) are shared
// with the other two child kinds; these two are not, since no other
// resource in this operator holds unrecoverable, show-once server-issued
// material.
const (
// ReasonWebhookSecretLost: status.integrationID is set but the webhook
// Secret is gone -- fail-closed, not self-healed (DESIGN.md §5): the
// key is genuinely unrecoverable, so silently minting a replacement
// would rotate a live webhook URL with no corresponding spec change to
// explain why.
ReasonWebhookSecretLost = "WebhookSecretLost"
// ReasonKindRotated: a Warning event reason only (never a condition) --
// fired once, the moment a spec.kind change deletes the old
// integration and creates a new one, so the rotation is loud in
// `kubectl describe`/`get events` even though the condition right
// after is the same ReasonChildAdopted the initial create used.
ReasonKindRotated = "KindRotated"
)
// TerdutAlertSourceStatus defines the observed state of TerdutAlertSource.
type TerdutAlertSourceStatus struct {
// +listType=map
// +listMapKey=type
// +optional
Conditions []metav1.Condition `json:"conditions,omitempty"`
// integrationID is the server-side id.
// +optional
IntegrationID int64 `json:"integrationID,omitempty"`
// lastAppliedKind is the kind the currently-live integration was
// actually created with -- compared against spec.kind on every
// reconcile to detect the one spec change that requires
// delete-and-recreate (DESIGN.md §5), since terdut-server's own API has
// no way to read a live integration's kind back for comparison.
// +optional
LastAppliedKind string `json:"lastAppliedKind,omitempty"`
// webhookURLSecretRef names the generated Secret holding "url" and
// "key" -- the integration's webhook address and credential, shown by
// terdut-server's API exactly once, at creation (DESIGN.md §4.5), and
// never re-readable afterward, including from this status. Lives in
// this CR's own namespace with a plain OwnerReference (§7) -- unlike
// TerdutServer/TerdutTeam's credential Secrets, this one never crosses
// namespaces, so no finalizer cleanup is needed for it specifically.
// +optional
WebhookURLSecretRef *LocalSecretRef `json:"webhookURLSecretRef,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="IntegrationID",type=integer,JSONPath=`.status.integrationID`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason`
// TerdutAlertSource is the Schema for the terdutalertsources API
type TerdutAlertSource struct {
metav1.TypeMeta `json:",inline"`
// metadata is a standard object metadata
// +optional
metav1.ObjectMeta `json:"metadata,omitzero"`
// spec defines the desired state of TerdutAlertSource
// +required
Spec TerdutAlertSourceSpec `json:"spec"`
// status defines the observed state of TerdutAlertSource
// +optional
Status TerdutAlertSourceStatus `json:"status,omitzero"`
}
// +kubebuilder:object:root=true
// TerdutAlertSourceList contains a list of TerdutAlertSource
type TerdutAlertSourceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitzero"`
Items []TerdutAlertSource `json:"items"`
}
func init() {
SchemeBuilder.Register(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &TerdutAlertSource{}, &TerdutAlertSourceList{})
return nil
})
}
+117
View File
@@ -135,6 +135,21 @@ func (in *ImageSpec) DeepCopy() *ImageSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalSecretRef) DeepCopyInto(out *LocalSecretRef) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalSecretRef.
func (in *LocalSecretRef) DeepCopy() *LocalSecretRef {
if in == nil {
return nil
}
out := new(LocalSecretRef)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NetworkingSpec) DeepCopyInto(out *NetworkingSpec) {
*out = *in
@@ -240,6 +255,108 @@ func (in *SweeperSpec) DeepCopy() *SweeperSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutAlertSource) DeepCopyInto(out *TerdutAlertSource) {
*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 TerdutAlertSource.
func (in *TerdutAlertSource) DeepCopy() *TerdutAlertSource {
if in == nil {
return nil
}
out := new(TerdutAlertSource)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutAlertSource) 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 *TerdutAlertSourceList) DeepCopyInto(out *TerdutAlertSourceList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
in, out := &in.Items, &out.Items
*out = make([]TerdutAlertSource, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutAlertSourceList.
func (in *TerdutAlertSourceList) DeepCopy() *TerdutAlertSourceList {
if in == nil {
return nil
}
out := new(TerdutAlertSourceList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *TerdutAlertSourceList) 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 *TerdutAlertSourceSpec) DeepCopyInto(out *TerdutAlertSourceSpec) {
*out = *in
out.TeamRef = in.TeamRef
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutAlertSourceSpec.
func (in *TerdutAlertSourceSpec) DeepCopy() *TerdutAlertSourceSpec {
if in == nil {
return nil
}
out := new(TerdutAlertSourceSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutAlertSourceStatus) DeepCopyInto(out *TerdutAlertSourceStatus) {
*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])
}
}
if in.WebhookURLSecretRef != nil {
in, out := &in.WebhookURLSecretRef, &out.WebhookURLSecretRef
*out = new(LocalSecretRef)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutAlertSourceStatus.
func (in *TerdutAlertSourceStatus) DeepCopy() *TerdutAlertSourceStatus {
if in == nil {
return nil
}
out := new(TerdutAlertSourceStatus)
in.DeepCopyInto(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