Use a typed escalation fallback instead of fallbackTopic

TerdutTeam spec.escalation.fallback is now {type, address}, mirroring the
server's notification channel model. CRDs regenerated.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-12 21:00:40 +02:00
parent 7fb8042265
commit 2d8fe0f275
11 changed files with 117 additions and 16 deletions
+1 -1
View File
@@ -197,7 +197,7 @@ spec:
oidc: {memberGroup: terdut-platform-members, ownerGroup: terdut-platform-owners}
escalation:
repeatCount: 2
fallbackTopic: platform-oncall
fallback: {type: ntfy, address: platform-oncall}
levels:
- timeout: 5m
targets: [{kind: oncall}, {kind: user, username: alice}]
+21 -1
View File
@@ -94,6 +94,24 @@ type EscalationLevel struct {
Targets []EscalationTarget `json:"targets"`
}
// NotificationTarget is somewhere a page can go without belonging to a person:
// a notification provider the server has configured, and an address that
// provider understands.
type NotificationTarget struct {
// type is the notification provider, e.g. "ntfy". The server refuses a
// provider it has not been configured with.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=32
Type string `json:"type"`
// address is where that provider delivers it: an ntfy topic, say.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=256
Address string `json:"address"`
}
// EscalationSpec is a team's escalation ladder.
type EscalationSpec struct {
// +kubebuilder:validation:Minimum=0
@@ -101,8 +119,10 @@ type EscalationSpec struct {
// +optional
RepeatCount int64 `json:"repeatCount,omitempty"`
// fallback is paged once when the ladder has run out of levels and repeats.
// Omitted, the chain just ends there.
// +optional
FallbackTopic string `json:"fallbackTopic,omitempty"`
Fallback *NotificationTarget `json:"fallback,omitempty"`
// +required
// +kubebuilder:validation:MinItems=1
+20
View File
@@ -110,6 +110,11 @@ func (in *EscalationLevel) DeepCopy() *EscalationLevel {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *EscalationSpec) DeepCopyInto(out *EscalationSpec) {
*out = *in
if in.Fallback != nil {
in, out := &in.Fallback, &out.Fallback
*out = new(NotificationTarget)
**out = **in
}
if in.Levels != nil {
in, out := &in.Levels, &out.Levels
*out = make([]EscalationLevel, len(*in))
@@ -189,6 +194,21 @@ func (in *NetworkingSpec) DeepCopy() *NetworkingSpec {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NotificationTarget) DeepCopyInto(out *NotificationTarget) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NotificationTarget.
func (in *NotificationTarget) DeepCopy() *NotificationTarget {
if in == nil {
return nil
}
out := new(NotificationTarget)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *NotifySpec) DeepCopyInto(out *NotifySpec) {
*out = *in
@@ -116,8 +116,28 @@ spec:
escalation is this team's escalation ladder. Omitted, the team has none
(the server's plain reminder behaviour applies).
properties:
fallbackTopic:
type: string
fallback:
description: |-
fallback is paged once when the ladder has run out of levels and repeats.
Omitted, the chain just ends there.
properties:
address:
description: 'address is where that provider delivers it:
an ntfy topic, say.'
maxLength: 256
minLength: 1
type: string
type:
description: |-
type is the notification provider, e.g. "ntfy". The server refuses a
provider it has not been configured with.
maxLength: 32
minLength: 1
type: string
required:
- address
- type
type: object
levels:
items:
description: |-
@@ -113,8 +113,28 @@ spec:
escalation is this team's escalation ladder. Omitted, the team has none
(the server's plain reminder behaviour applies).
properties:
fallbackTopic:
type: string
fallback:
description: |-
fallback is paged once when the ladder has run out of levels and repeats.
Omitted, the chain just ends there.
properties:
address:
description: 'address is where that provider delivers it:
an ntfy topic, say.'
maxLength: 256
minLength: 1
type: string
type:
description: |-
type is the notification provider, e.g. "ntfy". The server refuses a
provider it has not been configured with.
maxLength: 32
minLength: 1
type: string
required:
- address
- type
type: object
levels:
items:
description: |-
+6
View File
@@ -41,6 +41,12 @@ spec:
sweeper:
staleAfter: 6h
archiveAfter: 168h
# No ntfy anywhere in this demo can actually be reached; the URL only has to
# exist so the server has an "ntfy" notification provider and accepts the
# teams' escalation fallback (02, 03), which names one. A server with no
# provider refuses a fallback it could never deliver.
notify:
ntfyURL: "http://ntfy.invalid"
# No oidc block: password login only, so there's nothing external to
# register a redirect URI with before this demo can sign in.
passwordLogin: true
+3 -1
View File
@@ -24,7 +24,9 @@ spec:
# until that person exists -- run-demo.sh creates alice for exactly that.
escalation:
repeatCount: 2
fallbackTopic: platform-fallback
fallback:
type: ntfy
address: platform-fallback
levels:
- timeout: 5m
targets:
+3 -1
View File
@@ -8,7 +8,9 @@ spec:
displayName: Payments
escalation:
repeatCount: 1
fallbackTopic: payments-fallback
fallback:
type: ntfy
address: payments-fallback
levels:
- timeout: 5m
targets:
+7 -3
View File
@@ -55,10 +55,14 @@ func buildEscalationRequest(spec *terdutv1alpha1.EscalationSpec) (tdclient.SetEs
Targets: targets,
}
}
var fallback tdclient.NotificationTarget
if spec.Fallback != nil {
fallback = tdclient.NotificationTarget{Type: spec.Fallback.Type, Address: spec.Fallback.Address}
}
return tdclient.SetEscalationRequest{
RepeatCount: spec.RepeatCount,
FallbackTopic: spec.FallbackTopic,
Levels: levels,
RepeatCount: spec.RepeatCount,
Fallback: fallback,
Levels: levels,
}, nil
}
@@ -300,7 +300,7 @@ var _ = Describe("TerdutTeam Controller", func() {
Describe("spec.escalation", func() {
ladder := func() *terdutv1alpha1.EscalationSpec {
return &terdutv1alpha1.EscalationSpec{
RepeatCount: 2, FallbackTopic: "oncall",
RepeatCount: 2, Fallback: &terdutv1alpha1.NotificationTarget{Type: "ntfy", Address: "oncall"},
Levels: []terdutv1alpha1.EscalationLevel{
{Timeout: "5m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetOncall}}},
{Timeout: "15m", Targets: []terdutv1alpha1.EscalationTarget{{Kind: terdutv1alpha1.EscalationTargetUser, Username: "alice"}}},
@@ -320,7 +320,7 @@ var _ = Describe("TerdutTeam Controller", func() {
got := fake.escalation[getTeam(ctx).Status.TeamID]
Expect(got.RepeatCount).To(Equal(int64(2)))
Expect(got.FallbackTopic).To(Equal("oncall"))
Expect(got.Fallback).To(Equal(tdclient.NotificationTarget{Type: "ntfy", Address: "oncall"}))
Expect(got.Levels).To(HaveLen(2))
Expect(got.Levels[0].TimeoutSeconds).To(Equal(int64(300)))
Expect(got.Levels[1].Targets[0]).To(Equal(tdclient.EscalationTargetRequest{Kind: "user", Username: "alice"}))
+10 -3
View File
@@ -208,10 +208,17 @@ type EscalationLevelRequest struct {
Targets []EscalationTargetRequest `json:"targets"`
}
// NotificationTarget is a notification provider and an address it understands.
// Both empty means none.
type NotificationTarget struct {
Type string `json:"type"`
Address string `json:"address"`
}
type SetEscalationRequest struct {
RepeatCount int64 `json:"repeat_count"`
FallbackTopic string `json:"fallback_topic"`
Levels []EscalationLevelRequest `json:"levels"`
RepeatCount int64 `json:"repeat_count"`
Fallback NotificationTarget `json:"fallback"`
Levels []EscalationLevelRequest `json:"levels"`
}
// SetEscalation calls PUT /api/teams/{teamID}/escalation -- an upsert