Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam

Credentials: the TerdutServer controller generates <name>-operator-key in
the server's own namespace (owned by it) and hands it to the pods as
TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it
at every start. A replaced Secret rolls the pods. The bootstrap handshake,
the checkpoint Secret, per-team service accounts and credentials Secrets,
BootstrapStateLost and credentials.deletionPolicy are gone.

CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule
and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[]
on the team (matched by name, extras removed); team invites are removed.
A team is created under the identity <namespace>/<name> (external_id), so a
retry, a lost status or a deleted team heal by repeating the same call, and
a display name owned by another team is TeamNameTaken instead of an
adoption. The server resolves escalation usernames (UnknownUser condition).
OIDC claim names and trustEmail are spec fields.

Fixes: query values are URL-escaped; every delete treats 404 as success;
deleting a team no longer depends on allowedTeams consent; a switch or
integration deleted on the server is recreated; unnamed switches take the
CR's name.

Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces
and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo
(run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays
cluster-wide, now stated in DESIGN.md section 9.

Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
Niklas Ye
2026-10-09 14:56:22 +02:00
parent b0a431f2a4
commit e1103f2b7d
92 changed files with 2082 additions and 7561 deletions
-98
View File
@@ -1,98 +0,0 @@
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
})
}
-137
View File
@@ -1,137 +0,0 @@
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
})
}
+24 -91
View File
@@ -7,12 +7,11 @@ import (
"k8s.io/apimachinery/pkg/util/intstr"
)
// SecretKeyRef names one data key inside a Secret. Every use of this type in
// TerdutServerSpec resolves in the TerdutServer's own namespace (it's wired
// straight into the Deployment's pod spec as a secretKeyRef env source,
// which Kubernetes itself only allows same-namespace) -- unlike the
// generated credentials Secret (DESIGN.md §6), which always lives in the
// operator's own namespace and is never referenced through this type.
// SecretKeyRef names one data key inside a Secret in the TerdutServer's own
// namespace. Every use of this type is wired into the Deployment's pod spec as
// a secretKeyRef env source, which Kubernetes only allows same-namespace --
// including status.credentialsSecretRef, the operator key the controller
// generates there.
type SecretKeyRef struct {
// name is the Secret's name.
// +kubebuilder:validation:MinLength=1
@@ -23,37 +22,6 @@ type SecretKeyRef struct {
Key string `json:"key"`
}
// CredentialsDeletionPolicy is what deleting a TerdutServer does to the
// instance credential Secret the operator generated for it.
// +kubebuilder:validation:Enum=Retain;Delete
type CredentialsDeletionPolicy string
const (
// CredentialsRetain keeps the Secret when the TerdutServer is deleted, so
// a TerdutServer recreated with the same name and namespace against the
// same database adopts it again instead of finding a server it cannot
// log in to. Deleting a TerdutServer never touches its database, so the
// operator's service account is still there to be reused. The default.
CredentialsRetain CredentialsDeletionPolicy = "Retain"
// CredentialsDelete removes the Secret with the TerdutServer. Choose it
// when the database goes too, or when the credential must not outlive the
// object.
CredentialsDelete CredentialsDeletionPolicy = "Delete"
)
// CredentialsSpec configures the lifecycle of the generated instance
// credential.
type CredentialsSpec struct {
// deletionPolicy: whether the instance credential Secret is kept
// (Retain, the default) or removed (Delete) when this TerdutServer is
// deleted. A kept Secret is only ever adopted after the server accepts
// its token, so one left over from a database that has since been reset
// is ignored and replaced.
// +kubebuilder:default=Retain
// +optional
DeletionPolicy CredentialsDeletionPolicy `json:"deletionPolicy,omitempty"`
}
// ImageSpec is the terdut-server image to run.
type ImageSpec struct {
// +kubebuilder:validation:MinLength=1
@@ -135,18 +103,6 @@ type SweeperSpec struct {
ArchiveAfter string `json:"archiveAfter,omitempty"`
}
// DeadmanSpec controls dead man's switch alerts. Matchers/Timeout/Severity
// map straight to TERDUT_DEADMAN_MATCHERS/TERDUT_DEADMAN_TIMEOUT/
// TERDUT_DEADMAN_SEVERITY.
type DeadmanSpec struct {
// +optional
Matchers string `json:"matchers,omitempty"`
// +optional
Timeout string `json:"timeout,omitempty"`
// +optional
Severity string `json:"severity,omitempty"`
}
// NotifySpec controls push notifications via ntfy. Empty ntfyURL disables
// notifications entirely (matches the chart's own default).
type NotifySpec struct {
@@ -162,11 +118,7 @@ type NotifySpec struct {
TokenSecretRef *SecretKeyRef `json:"tokenSecretRef,omitempty"`
}
// OIDCSpec controls single sign-on. Fields the chart also exposes but
// DESIGN.md's spec doesn't (usernameClaim, emailClaim, groupsClaim,
// trustEmail) use terdut-server's own defaults
// (preferred_username/email/groups/false) rather than being added here
// speculatively.
// OIDCSpec controls single sign-on.
type OIDCSpec struct {
// +optional
Enabled bool `json:"enabled,omitempty"`
@@ -189,6 +141,21 @@ type OIDCSpec struct {
// +kubebuilder:default="12h"
// +optional
SessionMaxAge string `json:"sessionMaxAge,omitempty"`
// usernameClaim, emailClaim and groupsClaim name the ID token claims read.
// +kubebuilder:default="preferred_username"
// +optional
UsernameClaim string `json:"usernameClaim,omitempty"`
// +kubebuilder:default="email"
// +optional
EmailClaim string `json:"emailClaim,omitempty"`
// +kubebuilder:default="groups"
// +optional
GroupsClaim string `json:"groupsClaim,omitempty"`
// trustEmail links a sign-in to an existing local user by email even when
// the provider does not vouch the address is verified (Authentik reports
// email_verified false unless told otherwise).
// +optional
TrustEmail bool `json:"trustEmail,omitempty"`
}
// AllowedTeamsNamespaces gates which namespaces a TerdutTeam may resolve a
@@ -352,14 +319,6 @@ type TerdutServerSpec struct {
// +optional
Sweeper SweeperSpec `json:"sweeper,omitempty"`
// +optional
Deadman DeadmanSpec `json:"deadman,omitempty"`
// credentials: what happens to the instance credential this operator
// generates for the server.
// +optional
Credentials CredentialsSpec `json:"credentials,omitempty"`
// +optional
Notify NotifySpec `json:"notify,omitempty"`
@@ -389,15 +348,6 @@ const (
// ConditionReady is the standard top-level condition every CRD carries
// (DESIGN.md §7).
ConditionReady = "Ready"
// ConditionDatabaseReady reflects whether the configured database is
// usable -- for postgresClusterRef, whether the Zalando CR and its
// generated credentials Secret both resolved; for a plain dsn, always
// true once set (DESIGN.md §8: "no connectivity check beyond what the
// Deployment's own readiness probe already gives").
ConditionDatabaseReady = "DatabaseReady"
// ConditionBootstrapped reflects whether a working credential has been
// acquired via self-registration (DESIGN.md §6).
ConditionBootstrapped = "Bootstrapped"
)
// Condition reasons this controller sets.
@@ -415,16 +365,6 @@ const (
// instead -- but a TerdutServer that explicitly asks for it still needs
// to say clearly that it can't be satisfied).
ReasonPostgresOperatorCRDNotInstalled = "PostgresOperatorCRDNotInstalled"
// ReasonBootstrapStateLost: a checkpointed admin credential
// (DESIGN.md §6) was lost after being used but before the lasting
// credential it was for could be persisted -- the one genuinely
// pathological case in the self-registration flow -- or the server's
// database is already bootstrapped and no credential for it survives
// (spec.credentials.deletionPolicy: Delete, or the Secret removed by
// hand). Fail-closed: the operator cannot mint a credential, and
// deleting and recreating the TerdutServer does not clear the database.
// Restore the Secret, or reset the server's database.
ReasonBootstrapStateLost = "BootstrapStateLost"
// ReasonAdopted: the happy path. A working credential is in hand, the
// Deployment has a ready replica, and the database (if postgresClusterRef)
// resolved.
@@ -445,16 +385,9 @@ type TerdutServerStatus struct {
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
// serviceName is the Service this controller created for the
// Deployment, so other objects can reference it without recomputing the
// naming convention.
// +optional
ServiceName string `json:"serviceName,omitempty"`
// credentialsSecretRef is the generated instance-scoped credential
// (DESIGN.md §6) -- pure output, always in the operator's own
// namespace, under a fixed data key ("token"). Set only once
// Bootstrapped is True.
// credentialsSecretRef is the operator key this controller generated for
// the server (TERDUT_OPERATOR_KEY): pure output, in the TerdutServer's own
// namespace and owned by it, under the data key "token".
// +optional
CredentialsSecretRef *SecretKeyRef `json:"credentialsSecretRef,omitempty"`
}
+120 -75
View File
@@ -27,52 +27,15 @@ type TerdutTeamOIDC struct {
OwnerGroup string `json:"ownerGroup,omitempty"`
}
// TerdutTeamInvite requests a standing invite link into this team, minted
// with the team's own team-scoped credential — requireTeamOwner already
// treats that credential as owner-equivalent for every /invites route
// (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is
// the real answer to "how does a human ever get a first login on a
// password-only, operator-managed install" (terdut-server#23): no signup_mode
// flip, no admin token, just a link redeemed the same way anyone else's
// invite would be.
type TerdutTeamInvite struct {
// enabled mints (and keeps refreshed ahead of terdut-server's own fixed
// 7-day TTL) an invite link while true. Flipping it back to false
// revokes the current one server-side rather than leaving it to expire
// on its own.
// +optional
Enabled bool `json:"enabled,omitempty"`
// role is what the invite grants: member or owner. Defaults to member —
// owner by default would make every invite link a standing
// administrative credential for the team, a much bigger blast radius
// than "let a human see the queue".
// +optional
// +kubebuilder:validation:Enum=member;owner
// +kubebuilder:default=member
Role string `json:"role,omitempty"`
// maxUses bounds how many times this link may be redeemed before it
// stops working, mirroring terdut-server's own 1-100 range
// (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for
// one specific person, not a standing door.
// +optional
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=100
// +kubebuilder:default=1
MaxUses int64 `json:"maxUses,omitempty"`
}
// TerdutTeamSpec defines the desired state of TerdutTeam.
type TerdutTeamSpec struct {
// serverRef names the TerdutServer this team belongs to.
// +required
ServerRef TerdutServerRef `json:"serverRef"`
// displayName is this team's name, both in terdut-server's own data
// (POST /api/teams {"name": ...}) and as the identity POST /api/teams
// and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
// create rule, via TEAM-LOOKUP.md).
// displayName is this team's name on the server. It can be changed freely:
// the team is found by the CR's own identity (<namespace>/<name>, sent as
// external_id), not by this name.
// +required
// +kubebuilder:validation:MinLength=1
DisplayName string `json:"displayName"`
@@ -80,8 +43,107 @@ type TerdutTeamSpec struct {
// +optional
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
// escalation is this team's escalation ladder. Omitted, the team has none
// (the server's plain reminder behaviour applies).
// +optional
Invite TerdutTeamInvite `json:"invite,omitempty"`
Escalation *EscalationSpec `json:"escalation,omitempty"`
// deadmanSwitches are this team's dead man's switches, by name. Switches on
// the server that are not listed here are removed: in operator mode this
// list is the whole truth.
// +listType=map
// +listMapKey=name
// +kubebuilder:validation:MaxItems=50
// +optional
DeadmanSwitches []DeadmanSwitchSpec `json:"deadmanSwitches,omitempty"`
}
// 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".
// +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"`
// +kubebuilder:validation:MaxLength=255
// +optional
Username string `json:"username,omitempty"`
}
// EscalationLevel is one rung of the ladder: how long to wait, and who to page
// if nobody has acknowledged by then.
type EscalationLevel struct {
// timeout is a Go duration string, e.g. "5m".
// +required
// +kubebuilder:validation:MaxLength=32
// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$`
Timeout string `json:"timeout"`
// +required
// +kubebuilder:validation:MinItems=1
// +kubebuilder:validation:MaxItems=20
Targets []EscalationTarget `json:"targets"`
}
// EscalationSpec is a team's escalation ladder.
type EscalationSpec struct {
// +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
// +kubebuilder:validation:MaxItems=10
Levels []EscalationLevel `json:"levels"`
}
// DeadmanSwitchSpec is one dead man's switch: the absence of an alert matching
// matcher for longer than timeout opens an incident.
type DeadmanSwitchSpec struct {
// name identifies the switch within the team.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=100
Name string `json:"name"`
// matcher names the alerts this switch watches, e.g.
// "alertname=Watchdog,cluster=prod". One matcher per switch.
// +required
// +kubebuilder:validation:MinLength=1
// +kubebuilder:validation:MaxLength=512
// +kubebuilder:validation:XValidation:rule="!self.contains(';')",message="one matcher per switch: add another entry instead of separating with ;"
Matcher string `json:"matcher"`
// timeout is a Go duration string, e.g. "15m".
// +required
// +kubebuilder:validation:MaxLength=32
// +kubebuilder:validation:Pattern=`^([0-9]+(\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$`
Timeout string `json:"timeout"`
// +kubebuilder:validation:Enum=critical;error;warning;info
// +kubebuilder:default=critical
// +optional
Severity string `json:"severity,omitempty"`
}
// TerdutTeamRef names the TerdutTeam a TerdutAlertSource belongs to. Always
// same-namespace as the CR itself.
type TerdutTeamRef struct {
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`
}
// Condition reasons this controller sets.
@@ -97,21 +159,28 @@ const (
// DESIGN.md §5's "every child requeues with backoff, no cross-
// controller RPC" rule.
ReasonWaitingForServer = "WaitingForServer"
// ReasonTeamNameTaken: the server already has a team with spec.displayName
// that belongs to a different TerdutTeam (or to a person). The name is global
// to the server, so the operator waits for one of them to change.
ReasonTeamNameTaken = "TeamNameTaken"
// ReasonUnknownUser: an escalation target's username matches no user on the
// server (yet).
ReasonUnknownUser = "UnknownUser"
// ReasonInvalidSpec: a duration in the spec does not parse.
ReasonInvalidSpec = "InvalidSpec"
// ReasonTeamAdopted: the happy path.
ReasonTeamAdopted = "Adopted"
)
// Condition reasons for spec.invite reconciliation (TerdutTeamInvite). Not
// surfaced on the Ready condition itself — an invite is a convenience, not
// a dependency anything else in this team's own readiness waits on — but
// recorded as Events and readable via `kubectl describe`.
// Condition reasons shared by the resources that hang off a TerdutTeam
// (currently TerdutAlertSource).
const (
// ReasonInviteMinted: spec.invite.enabled is true and status.inviteSecretRef
// is populated and live.
ReasonInviteMinted = "InviteMinted"
// ReasonInviteRevoked: spec.invite.enabled flipped back to false and the
// server-side invite was revoked (or there was nothing to revoke).
ReasonInviteRevoked = "InviteRevoked"
// ReasonTeamRefNotFound: spec.teamRef names no TerdutTeam (yet).
ReasonTeamRefNotFound = "TeamRefNotFound"
// ReasonWaitingForTeam: the referenced TerdutTeam exists but is not Ready.
ReasonWaitingForTeam = "WaitingForTeam"
// ReasonChildAdopted: the happy path.
ReasonChildAdopted = "Adopted"
)
// TerdutTeamStatus defines the observed state of TerdutTeam.
@@ -126,30 +195,6 @@ type TerdutTeamStatus struct {
// +optional
TeamID int64 `json:"teamID,omitempty"`
// credentialsSecretRef is this team's own scoped credential
// (DESIGN.md §6 point 3) -- pure output, always in the operator's own
// namespace, under a fixed data key ("token").
// +optional
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"`
// inviteSecretRef is this team's current invite link, if spec.invite.enabled.
// Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN
// namespace, not the operator's: an invite is bounded, limited-use, and
// meant for this namespace's own human operators to read and hand out,
// not a durable high-privilege credential — same shape as
// TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's
// cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled
// is false or unset.
// +optional
InviteSecretRef *LocalSecretRef `json:"inviteSecretRef,omitempty"`
// +optional
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
}
+37 -249
View File
@@ -47,21 +47,6 @@ func (in *AllowedTeamsNamespaces) DeepCopy() *AllowedTeamsNamespaces {
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CredentialsSpec) DeepCopyInto(out *CredentialsSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CredentialsSpec.
func (in *CredentialsSpec) DeepCopy() *CredentialsSpec {
if in == nil {
return nil
}
out := new(CredentialsSpec)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DatabaseSpec) DeepCopyInto(out *DatabaseSpec) {
*out = *in
@@ -88,16 +73,16 @@ func (in *DatabaseSpec) DeepCopy() *DatabaseSpec {
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *DeadmanSpec) DeepCopyInto(out *DeadmanSpec) {
func (in *DeadmanSwitchSpec) DeepCopyInto(out *DeadmanSwitchSpec) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeadmanSpec.
func (in *DeadmanSpec) DeepCopy() *DeadmanSpec {
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DeadmanSwitchSpec.
func (in *DeadmanSwitchSpec) DeepCopy() *DeadmanSwitchSpec {
if in == nil {
return nil
}
out := new(DeadmanSpec)
out := new(DeadmanSwitchSpec)
in.DeepCopyInto(out)
return out
}
@@ -122,6 +107,28 @@ func (in *EscalationLevel) DeepCopy() *EscalationLevel {
return out
}
// 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.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 EscalationSpec.
func (in *EscalationSpec) DeepCopy() *EscalationSpec {
if in == nil {
return nil
}
out := new(EscalationSpec)
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
@@ -496,207 +503,6 @@ func (in *TerdutAlertSourceStatus) DeepCopy() *TerdutAlertSourceStatus {
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.
func (in *TerdutServer) DeepCopyInto(out *TerdutServer) {
*out = *in
@@ -778,8 +584,6 @@ func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
out.Networking = in.Networking
in.Database.DeepCopyInto(&out.Database)
out.Sweeper = in.Sweeper
out.Deadman = in.Deadman
out.Credentials = in.Credentials
in.Notify.DeepCopyInto(&out.Notify)
in.OIDC.DeepCopyInto(&out.OIDC)
in.AllowedTeams.DeepCopyInto(&out.AllowedTeams)
@@ -828,7 +632,7 @@ func (in *TerdutTeam) DeepCopyInto(out *TerdutTeam) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
@@ -850,21 +654,6 @@ func (in *TerdutTeam) DeepCopyObject() runtime.Object {
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamInvite) DeepCopyInto(out *TerdutTeamInvite) {
*out = *in
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamInvite.
func (in *TerdutTeamInvite) DeepCopy() *TerdutTeamInvite {
if in == nil {
return nil
}
out := new(TerdutTeamInvite)
in.DeepCopyInto(out)
return out
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
*out = *in
@@ -932,7 +721,16 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
*out = *in
out.ServerRef = in.ServerRef
out.OIDC = in.OIDC
out.Invite = in.Invite
if in.Escalation != nil {
in, out := &in.Escalation, &out.Escalation
*out = new(EscalationSpec)
(*in).DeepCopyInto(*out)
}
if in.DeadmanSwitches != nil {
in, out := &in.DeadmanSwitches, &out.DeadmanSwitches
*out = make([]DeadmanSwitchSpec, len(*in))
copy(*out, *in)
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
@@ -955,16 +753,6 @@ func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
if in.CredentialsSecretRef != nil {
in, out := &in.CredentialsSecretRef, &out.CredentialsSecretRef
*out = new(SecretKeyRef)
**out = **in
}
if in.InviteSecretRef != nil {
in, out := &in.InviteSecretRef, &out.InviteSecretRef
*out = new(LocalSecretRef)
**out = **in
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.