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:
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user