From 2d8fe0f275877bcc76590910c52a567425d713fa Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Mon, 12 Oct 2026 21:00:40 +0200 Subject: [PATCH] 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 --- DESIGN.md | 2 +- api/v1alpha1/terdutteam_types.go | 22 ++++++++++++++++- api/v1alpha1/zz_generated.deepcopy.go | 20 ++++++++++++++++ .../crd/terdutteams.terdut.ryuvia.com.yaml | 24 +++++++++++++++++-- .../bases/terdut.ryuvia.com_terdutteams.yaml | 24 +++++++++++++++++-- examples/demo/01-server.yaml | 6 +++++ examples/demo/02-team-platform.yaml | 4 +++- examples/demo/03-team-payments.yaml | 4 +++- internal/controller/terdutteam_config.go | 10 +++++--- .../controller/terdutteam_controller_test.go | 4 ++-- internal/tdclient/client.go | 13 +++++++--- 11 files changed, 117 insertions(+), 16 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index dea1db6..9493d3f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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}] diff --git a/api/v1alpha1/terdutteam_types.go b/api/v1alpha1/terdutteam_types.go index 7737ad8..77eb0f5 100644 --- a/api/v1alpha1/terdutteam_types.go +++ b/api/v1alpha1/terdutteam_types.go @@ -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 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index f8f3d7b..08ce7c1 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -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 diff --git a/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml b/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml index 876db1d..7d50c5c 100644 --- a/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml +++ b/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml @@ -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: |- diff --git a/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml index f6a38ff..d36a1ad 100644 --- a/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml +++ b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml @@ -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: |- diff --git a/examples/demo/01-server.yaml b/examples/demo/01-server.yaml index 8f8e43d..7f61dc5 100644 --- a/examples/demo/01-server.yaml +++ b/examples/demo/01-server.yaml @@ -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 diff --git a/examples/demo/02-team-platform.yaml b/examples/demo/02-team-platform.yaml index 769268a..3b94938 100644 --- a/examples/demo/02-team-platform.yaml +++ b/examples/demo/02-team-platform.yaml @@ -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: diff --git a/examples/demo/03-team-payments.yaml b/examples/demo/03-team-payments.yaml index 82b0a85..6f1202e 100644 --- a/examples/demo/03-team-payments.yaml +++ b/examples/demo/03-team-payments.yaml @@ -8,7 +8,9 @@ spec: displayName: Payments escalation: repeatCount: 1 - fallbackTopic: payments-fallback + fallback: + type: ntfy + address: payments-fallback levels: - timeout: 5m targets: diff --git a/internal/controller/terdutteam_config.go b/internal/controller/terdutteam_config.go index 003190b..17b6ab4 100644 --- a/internal/controller/terdutteam_config.go +++ b/internal/controller/terdutteam_config.go @@ -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 } diff --git a/internal/controller/terdutteam_controller_test.go b/internal/controller/terdutteam_controller_test.go index a02bd87..ac441f1 100644 --- a/internal/controller/terdutteam_controller_test.go +++ b/internal/controller/terdutteam_controller_test.go @@ -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"})) diff --git a/internal/tdclient/client.go b/internal/tdclient/client.go index b0d3b7d..7607a67 100644 --- a/internal/tdclient/client.go +++ b/internal/tdclient/client.go @@ -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