8 Commits

Author SHA1 Message Date
Niklas Ye d50248531c Set the chart's placeholder version to 0.4.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 59s
CI / test (push) Successful in 2m10s
Release / test (push) Successful in 7m54s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 6m25s
Release / scan-image (push) Successful in 36s
2026-10-03 16:22:15 +02:00
Niklas Ye 4007f54279 Default TerdutServer.spec.replicas to 2 and switch to RollingUpdate
CI / chart (push) Successful in 1s
CI / security (push) Successful in 59s
CI / test (push) Has been cancelled
Mirrors charts/terdut-server's own deployment.yaml change: v0.36.0 put
the sweeper, the notifier and the migration runner each behind a
Postgres advisory lock, and gave incident creation its own conflict
resolution, so the Recreate strategy and replicas-stays-at-1 guidance
this controller carried (explicitly tracking that chart's comment)
are no longer load-bearing.

spec.replicas' +kubebuilder:default moves 1 -> 2 (config/crd/bases and
the chart's CRD template regenerated via controller-gen and
kubebuilder's helm plugin respectively, then hand-verified identical
to the generator's own output rather than trusting a bulk regen --
the plugin's --output-dir charts writes a fresh charts/chart scaffold
rather than updating charts/terdut-operator in place, so only the
diff was taken, not the whole tree). terdutserver_deployment.go's
same-value fallback (reachable only for a TerdutServer stored before
this default existed) moves with it, and its Strategy changes from
Recreate to RollingUpdate with no explicit maxUnavailable/maxSurge --
the 25%/25% default rounds to 0/1 at replicas: 2, already
zero-downtime.

DESIGN.md's three places asserting multi-replica isn't a supported
topology (the illustrative spec.replicas YAML, spec.pod.affinity's
rationale, and the HPA deferred-feature note) are corrected to match;
the HPA note now gives its own standing reason (no scaling metric or
bounds decided yet) rather than a contradiction that no longer holds.

The chart's optional terdutServer.replicas sample value moves 1 -> 2
alongside it. image.tag must be v0.36.0 or newer for any of this to
hold -- stated in both the CRD field's doc comment and the chart
value's comment, not enforced in code, same stance the chart takes on
every other version-coupled assumption.

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-03 12:35:18 +02:00
Niklas Ye 88172ade29 Set the chart's placeholder version to 0.3.0
CI / chart (push) Successful in 1s
CI / security (push) Successful in 59s
CI / test (push) Successful in 1m53s
Release / test (push) Successful in 1m45s
Release / chart (push) Successful in 2s
Release / image (push) Successful in 5m27s
Release / scan-image (push) Successful in 2s
2026-10-02 22:19:18 +02:00
niklas 478ae6284a Merge pull request 'TerdutTeam: mint and surface a real invite link (spec.invite)' (#5) from terdutteam-invite-minting into main
CI / test (push) Has been cancelled
CI / chart (push) Successful in 1s
CI / security (push) Successful in 59s
2026-10-02 20:17:21 +00:00
niklas fcc80b32d5 Merge pull request 'examples/demo: add run-demo.sh, an automated kind-cluster demo' (#4) from examples-demo/run-demo-script into main
CI / chart (push) Successful in 2s
CI / security (push) Successful in 1m31s
CI / test (push) Successful in 3m30s
2026-10-02 20:13:36 +00:00
Niklas Ye 4aa4f17c42 examples/demo: bump terdut-server to v0.34.0 (the service-account fix)
CI / chart (pull_request) Successful in 2s
CI / security (pull_request) Successful in 1m18s
CI / test (pull_request) Successful in 3m40s
Required for this demo to actually exercise the fix for
niklas/terdut-operator#3 -- v0.33.2 still has the authorization gap this
demo hit live (callerMayManageServiceAccount had no branch letting an
instance-scoped account adopt a team-scoped account's key).
2026-10-02 22:13:26 +02:00
Niklas Ye a0ea13955e TerdutTeam: mint and surface a real invite link (spec.invite)
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 52s
CI / test (pull_request) Successful in 2m43s
The actual fix for the human-onboarding gap niklas/terdut-server#23 found --
not a terdut-server change at all. A team-scoped credential is already
owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites
(requireTeamOwner's synthetic-membership mechanism, ratified not
accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption
bypasses signup_mode entirely -- this TerdutTeam controller just never
grew a feature to use either fact.

New spec.invite{enabled, role (member|owner, default member), maxUses
(1-100, default 1)} and status.inviteSecretRef. The Secret lives in the
TerdutTeam's OWN namespace, not the operator's: unlike
status.credentialsSecretRef (a durable, high-privilege credential, kept
operator-side per DESIGN.md §6), an invite is bounded and limited-use,
meant for this namespace's own human operators to read and hand out --
same precedent as TerdutAlertSource's status.webhookURLSecretRef, same-
namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects
it automatically.

internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled,
refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the
Secret's own stored expiresAt, no extra server round-trip per reconcile),
revokes server-side and deletes the Secret when flipped back to false. A
lost invite Secret is silently re-minted rather than treated as
unrecoverable the way TerdutAlertSource's webhook key is -- nothing
external holds a durable dependency on one specific invite link staying
stable, it's read once by one human and handed out.

New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint
into the team's own namespace, refresh-before-expiry, revoke-on-disable
(internal/controller/terdutteam_controller_test.go's new "spec.invite"
Describe block), plus the fake server growing invite support
(terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was
split further (deadman switches into their own handleDeadmanSubPath,
matching the existing handleIntegrationSubPath precedent) to stay under
golangci-lint's gocyclo threshold with the new route added.

examples/demo updated to prove this end to end: 02-team-platform.yaml
turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the
psql signup_mode flip + a direct team_members INSERT) are replaced by
redeem_platform_invite (reads status.inviteSecretRef, a real POST
/api/signup with the invite token) and join_payments_team (POST
/api/teams/{teamID}/members using Payments' own credential and alice's
user id resolved via GET /api/users, deliberately not given its own
spec.invite, so the demo shows both onboarding paths this feature
unlocks) -- zero kubectl exec/psql calls remain anywhere in the script.
README.md's "First login" section rewritten to match; it no longer
documents the admin-token curl call that 403s against current
terdut-server (niklas/terdut-server#23).

Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix
for terdut-operator#3) being released before this is deployed for real --
not required to build or test this change itself, since the envtest fake
never modeled that authorization gap to begin with.
2026-10-02 22:04:56 +02:00
Niklas Ye 2a08a8cd8e examples/demo: add run-demo.sh, an automated kind-cluster demo
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 1m5s
CI / test (pull_request) Successful in 2m49s
One script, two modes (run-demo.sh / run-demo.sh --teardown), that takes a
fresh empty kind cluster all the way to a working demo: creates the
cluster if needed, helm-installs this chart, applies every CRD kind in
this directory, waits for all nine objects to go Ready, then does what
the README's own first-login section cannot (see niklas/terdut-server#23
and niklas/terdut-operator#3 -- no service-account credential this
operator holds can ever call /api/admin/settings or POST /api/users) by
reaching into the demo's own throwaway Postgres directly: flips
signup_mode to open, signs alice up for real over the ordinary signup
endpoint, and joins her to both Platform and Payments (open signup always
creates its own new team, never joins an existing one by name, so
without this she'd have a working login that can't see a single incident
this demo fires -- /api/incidents and /api/alerts are both scoped to the
caller's own team memberships). Finishes by port-forwarding the service
and firing fire-alerts.sh at both teams, so a fresh run already has
visible incidents waiting in the web UI.

Verified end to end against a real kind cluster, including a second,
genuinely-fresh run that hit niklas/terdut-operator#3 live (terdutteam-
platform wedged in the 403 retry loop that issue describes) -- confirmed
the script itself fails cleanly on that (clear FAILED message, correct
exit code, no orphaned port-forward) rather than hanging or leaving a
mess, which is the most this script can do about a bug in the operator
it's driving.
2026-10-02 21:23:44 +02:00
21 changed files with 1020 additions and 70 deletions
+11 -9
View File
@@ -153,7 +153,7 @@ spec:
image:
repository: git.ryuvia.com/niklas/terdut-server
tag: v0.9.3
replicas: 1 # terdut-server is not horizontally-scale-tested; keep the field, default 1
replicas: 2 # default since terdut-server v0.36.0's advisory locks; see TerdutServerSpec.Replicas
networking:
hostname: terdut.example.com
servicePort: 8080
@@ -244,10 +244,10 @@ no custom wrapper buys anything for any of these, matching how
CloudNativePG and the Zalando postgres-operator both expose the same
knobs. `affinity` is pure user-supplied passthrough, not a
toggle-plus-generated-default the way a multi-replica-aware operator's
pod anti-affinity typically is: this operator never auto-generates
affinity of its own, since `replicas` above 1 isn't a supported topology
(the sweeper/notifier singleton constraint, §4.1's own illustrative YAML
comment). `spec.pod.disruptionBudget` is the one field here that isn't a
pod anti-affinity typically is: even though `replicas` now defaults to 2
(terdut-server v0.36.0's advisory locks made that safe, §4.1's own
illustrative YAML comment), this operator still never auto-generates
affinity of its own. `spec.pod.disruptionBudget` is the one field here that isn't a
straight PodTemplateSpec knob — when set, the controller reconciles a
`PodDisruptionBudget` selecting this `TerdutServer`'s pods; clearing it
deletes any it previously created (§7). `minAvailable`/`maxUnavailable`
@@ -832,10 +832,12 @@ what it was, a separate install, until someone deletes it.
- Automatic Deployment restart on upstream Postgres credential rotation.
- `spec.pod.priorityClassName`, pod-label passthrough beyond
`spec.pod.annotations`, and a HorizontalPodAutoscaler for `TerdutServer`
— all considered alongside §4.1's `spec.pod` and explicitly left out of
that round: an HPA in particular would actively contradict
`spec.replicas`'s own stance that this operator doesn't support more
than one replica (the sweeper/notifier singleton constraint).
— all considered alongside §4.1's `spec.pod` and left out of that round.
An HPA no longer contradicts anything now that `spec.replicas` defaults
to 2 (terdut-server v0.36.0's advisory locks), but it is still a
separate, not-yet-made decision: a fixed replica count has no scaling
metric, min/max bounds, or cooldown behaviour to get right, and nobody
has asked for it yet.
- Admission webhooks / CEL-only validation limits (e.g. verifying a
`teamRef` exists at admission time rather than surfacing it as a status
condition after the fact).
+13 -9
View File
@@ -229,11 +229,11 @@ type PodSpec struct {
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
// affinity covers node affinity, pod affinity and pod anti-affinity in
// one field -- unlike a multi-replica-aware operator, this one never
// generates a default anti-affinity itself (replicas above 1 isn't a
// supported topology, see TerdutServerSpec.Replicas's own doc comment),
// so this is pure user-supplied passthrough, not a toggle-plus-generated-
// default.
// one field -- even though replicas now defaults to 2 (see
// TerdutServerSpec.Replicas's own doc comment), this operator still
// never generates a default anti-affinity of its own the way a
// multi-replica-aware operator typically would, so this stays pure
// user-supplied passthrough, not a toggle-plus-generated-default.
// +optional
Affinity *corev1.Affinity `json:"affinity,omitempty"`
@@ -301,10 +301,14 @@ type TerdutServerSpec struct {
// +required
Image ImageSpec `json:"image"`
// replicas. terdut-server is not horizontally-scale-tested; keep this
// at its default of 1 unless you've verified otherwise -- the sweeper
// and the notifier are unsynchronised singletons.
// +kubebuilder:default=1
// replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
// notifier and the migration runner each behind a Postgres advisory
// lock, and gave incident creation its own conflict resolution, so
// more than one replica no longer double-pages, races a migration, or
// drops a webhook payload. image.tag must be v0.36.0 or newer for
// that to hold -- an older terdut-server has none of these guards,
// and this field does not check the tag for you.
// +kubebuilder:default=2
// +optional
Replicas int32 `json:"replicas,omitempty"`
+63
View File
@@ -27,6 +27,42 @@ 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.
@@ -43,6 +79,9 @@ type TerdutTeamSpec struct {
// +optional
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
// +optional
Invite TerdutTeamInvite `json:"invite,omitempty"`
}
// Condition reasons this controller sets.
@@ -62,6 +101,19 @@ const (
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`.
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"
)
// TerdutTeamStatus defines the observed state of TerdutTeam.
type TerdutTeamStatus struct {
// +listType=map
@@ -87,6 +139,17 @@ type TerdutTeamStatus struct {
// +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"`
}
+21
View File
@@ -834,6 +834,21 @@ 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
@@ -901,6 +916,7 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
*out = *in
out.ServerRef = in.ServerRef
out.OIDC = in.OIDC
out.Invite = in.Invite
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
@@ -928,6 +944,11 @@ func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
*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.
+2 -2
View File
@@ -6,8 +6,8 @@ type: application
# These fields decide nothing: `make helm-package` passes --version and
# --app-version from the release tag (same reasoning as terdut-server's own
# chart). They're for whoever reads the tree before a tag exists.
version: 0.2.0
appVersion: "v0.2.0"
version: 0.4.0
appVersion: "v0.4.0"
keywords:
- kubernetes
@@ -327,11 +327,11 @@ spec:
affinity:
description: |-
affinity covers node affinity, pod affinity and pod anti-affinity in
one field -- unlike a multi-replica-aware operator, this one never
generates a default anti-affinity itself (replicas above 1 isn't a
supported topology, see TerdutServerSpec.Replicas's own doc comment),
so this is pure user-supplied passthrough, not a toggle-plus-generated-
default.
one field -- even though replicas now defaults to 2 (see
TerdutServerSpec.Replicas's own doc comment), this operator still
never generates a default anti-affinity of its own the way a
multi-replica-aware operator typically would, so this stays pure
user-supplied passthrough, not a toggle-plus-generated-default.
properties:
nodeAffinity:
description: Describes node affinity scheduling rules for
@@ -4304,11 +4304,15 @@ spec:
type: array
type: object
replicas:
default: 1
default: 2
description: |-
replicas. terdut-server is not horizontally-scale-tested; keep this
at its default of 1 unless you've verified otherwise -- the sweeper
and the notifier are unsynchronised singletons.
replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
notifier and the migration runner each behind a Postgres advisory
lock, and gave incident creation its own conflict resolution, so
more than one replica no longer double-pages, races a migration, or
drops a webhook payload. image.tag must be v0.36.0 or newer for
that to hold -- an older terdut-server has none of these guards,
and this field does not check the tag for you.
format: int32
type: integer
sweeper:
@@ -63,6 +63,47 @@ spec:
create rule, via TEAM-LOOKUP.md).
minLength: 1
type: string
invite:
description: |-
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.
properties:
enabled:
description: |-
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.
type: boolean
maxUses:
default: 1
description: |-
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.
format: int64
maximum: 100
minimum: 1
type: integer
role:
default: member
description: |-
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".
enum:
- member
- owner
type: string
type: object
oidc:
description: |-
TerdutTeamOIDC binds which identity-provider groups grant membership and
@@ -171,6 +212,24 @@ spec:
- key
- name
type: object
inviteSecretRef:
description: |-
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.
properties:
name:
description: name is the Secret's name.
minLength: 1
type: string
required:
- name
type: object
observedGeneration:
format: int64
type: integer
+5 -1
View File
@@ -233,7 +233,11 @@ terdutServer:
## Required when terdutServer.enabled.
# tag: ""
replicas: 1
## Safe above 1 since terdut-server v0.36.0 (image.tag above must be that or
## newer): the sweeper, notifier and migration runner are each behind a
## Postgres advisory lock, and incident creation resolves its own insert
## conflict, matching this CRD's own spec.replicas default.
replicas: 2
networking:
## Required when terdutServer.enabled -- terdut-server's own public
@@ -324,11 +324,11 @@ spec:
affinity:
description: |-
affinity covers node affinity, pod affinity and pod anti-affinity in
one field -- unlike a multi-replica-aware operator, this one never
generates a default anti-affinity itself (replicas above 1 isn't a
supported topology, see TerdutServerSpec.Replicas's own doc comment),
so this is pure user-supplied passthrough, not a toggle-plus-generated-
default.
one field -- even though replicas now defaults to 2 (see
TerdutServerSpec.Replicas's own doc comment), this operator still
never generates a default anti-affinity of its own the way a
multi-replica-aware operator typically would, so this stays pure
user-supplied passthrough, not a toggle-plus-generated-default.
properties:
nodeAffinity:
description: Describes node affinity scheduling rules for
@@ -4301,11 +4301,15 @@ spec:
type: array
type: object
replicas:
default: 1
default: 2
description: |-
replicas. terdut-server is not horizontally-scale-tested; keep this
at its default of 1 unless you've verified otherwise -- the sweeper
and the notifier are unsynchronised singletons.
replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
notifier and the migration runner each behind a Postgres advisory
lock, and gave incident creation its own conflict resolution, so
more than one replica no longer double-pages, races a migration, or
drops a webhook payload. image.tag must be v0.36.0 or newer for
that to hold -- an older terdut-server has none of these guards,
and this field does not check the tag for you.
format: int32
type: integer
sweeper:
@@ -60,6 +60,47 @@ spec:
create rule, via TEAM-LOOKUP.md).
minLength: 1
type: string
invite:
description: |-
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.
properties:
enabled:
description: |-
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.
type: boolean
maxUses:
default: 1
description: |-
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.
format: int64
maximum: 100
minimum: 1
type: integer
role:
default: member
description: |-
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".
enum:
- member
- owner
type: string
type: object
oidc:
description: |-
TerdutTeamOIDC binds which identity-provider groups grant membership and
@@ -168,6 +209,24 @@ spec:
- key
- name
type: object
inviteSecretRef:
description: |-
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.
properties:
name:
description: name is the Secret's name.
minLength: 1
type: string
required:
- name
type: object
observedGeneration:
format: int64
type: integer
+6 -1
View File
@@ -14,7 +14,12 @@ metadata:
spec:
image:
repository: git.ryuvia.com/niklas/terdut-server
tag: v0.33.2
# v0.34.0: fixes callerMayManageServiceAccount so an instance-scoped
# service account can adopt/rotate a key on a team-scoped account it
# didn't just create in the same call -- without this, terdutteam-*
# can wedge permanently on exactly the crash-window race this demo
# hit live (niklas/terdut-operator#3).
tag: v0.34.0
replicas: 1
networking:
hostname: terdut-operator-demo.example
+6
View File
@@ -15,3 +15,9 @@ spec:
name: terdut-operator-demo
displayName: Platform
# No oidc block: this demo is password-login only (01-server.yaml).
# A real invite link, minted via this team's own credential, is how
# run-demo.sh's alice actually gets in -- no signup_mode flip, no admin
# token (see niklas/terdut-server#23's resolution). Role/maxUses left at
# their defaults (member, 1): one link for one person.
invite:
enabled: true
+6
View File
@@ -6,3 +6,9 @@ spec:
serverRef:
name: terdut-operator-demo
displayName: Payments
# No spec.invite here, deliberately: run-demo.sh joins alice to this team
# through POST /api/teams/{teamID}/members instead (this team's own
# credential, same owner-equivalent reach spec.invite relies on, plus her
# user id resolved via GET /api/users), once she already has an account
# from Platform's invite -- showing both onboarding paths this feature
# unlocks, not just the one.
+32 -20
View File
@@ -10,6 +10,14 @@ This is a demo kit, not a reference deployment: `00-postgres.yaml` runs
Postgres with `emptyDir` storage and a password committed in this
directory. Throw the whole namespace away when you're done.
**Want this fully automated instead of walking through it by hand?**
`./run-demo.sh` does everything below itself, against a fresh (or
already-set-up) `kind` cluster — creates the cluster, installs the
operator, applies every CR here, signs `alice` in for real, and fires a
few alerts. `./run-demo.sh --help` for the knobs, `./run-demo.sh
--teardown` to tear it back down. The rest of this file is the manual
walkthrough it automates.
## Prerequisites
- The operator and its CRDs installed and running (`make install
@@ -74,30 +82,34 @@ exposing it for real (Gateway API, Istio, or a plain `Ingress`) instead.
The operator's own bootstrap (DESIGN.md §6) creates the first user through
`/api/bootstrap` and immediately mints itself a service-account token from
it — that account has no password, so there's nothing to sign in with yet.
`signup_mode` also defaults to `invite_only`, so open signup needs turning
on first, using the admin token the operator generated for itself:
it, then discards the bootstrap user's own key — nobody ever signs in as
that account, and `signup_mode` stays `invite_only` by default. **Don't try
to flip it via the operator's own token**: that token is a service account,
and `/api/admin/settings` is deliberately human-only on terdut-server
(`niklas/terdut-server#23` has the full reasoning — widening that gate was
the wrong fix).
The real path in: `02-team-platform.yaml` turns on `spec.invite`, so
Platform's own `TerdutTeam` mints a real invite link with its own
already-working team-scoped credential (the same reach that lets it manage
its own escalation policy, dead man's switches and integrations — owner-
equivalent, confirmed in terdut-server's `SERVICE-ACCOUNTS.md`). Invite
redemption bypasses `signup_mode` entirely, so this needs no admin
credential at all:
```sh
# Which namespace the operator itself runs in:
kubectl get deploy -A -l control-plane=controller-manager
# The Secret holding the operator's own admin token for this TerdutServer
# (cross-namespace from terdut-operator-demo, per DESIGN.md §7):
secretname=$(kubectl -n terdut-operator-demo get terdutserver terdut-operator-demo \
-o jsonpath='{.status.credentialsSecretRef.name}')
token=$(kubectl -n <operator-namespace-from-above> get secret "$secretname" \
-o jsonpath='{.data.token}' | base64 -d)
curl -X PUT http://localhost:8080/api/admin/settings \
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
-d '{"signup_mode":"open"}'
secretname=$(kubectl -n terdut-operator-demo get terdutteam terdutteam-platform \
-o jsonpath='{.status.inviteSecretRef.name}')
url=$(kubectl -n terdut-operator-demo get secret "$secretname" -o jsonpath='{.data.url}' | base64 -d)
echo "$url" # open this, or POST /api/signup with {"invite": "<the token after invite=>", ...}
```
Then sign up through the UI as a normal human account. `04-escalation-platform.yaml`
names a user `alice` at its first escalation level — sign up as `alice` if
you want that level to mean something rather than falling through to
on-call after 5 minutes.
`04-escalation-platform.yaml` names a user `alice` at its first escalation
level — sign up as `alice` if you want that level to mean something rather
than falling through to on-call after 5 minutes. `run-demo.sh` does exactly
this automatically (and also joins `alice` to Payments, which deliberately
has no `spec.invite` of its own — see that file's comment for the second
onboarding path this demonstrates).
## Fire some alerts
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bash
# Stands up the complete terdut demo (terdut-operator + every CRD kind this
# repo ships + a working local login + a few synthetic incidents) on a kind
# cluster, fully automated. Password login only -- this demo kit's own
# 01-server.yaml carries no oidc: block at all, so there is nothing to
# disable; OIDC is simply absent.
#
# Usage:
# ./run-demo.sh deploy the whole demo (idempotent: safe to
# re-run against a cluster that already has it)
# ./run-demo.sh --teardown delete the demo namespace (and, if
# TEARDOWN_CLUSTER=true, the kind cluster too)
# ./run-demo.sh --help
#
# All of the defaults below are overridable as environment variables.
set -euo pipefail
CLUSTER_NAME="${CLUSTER_NAME:-terdut-demo}"
NAMESPACE="${NAMESPACE:-terdut-operator-demo}"
OPERATOR_NAMESPACE="${OPERATOR_NAMESPACE:-terdut-operator-system}"
RELEASE_NAME="${RELEASE_NAME:-terdut-operator}"
ALICE_USERNAME="${ALICE_USERNAME:-alice}"
ALICE_EMAIL="${ALICE_EMAIL:-alice@terdut-demo.local}"
DEMO_PASSWORD="${DEMO_PASSWORD:-terdut-demo-1234}"
BASE_URL="${BASE_URL:-http://localhost:8080}"
LOCAL_PORT="${LOCAL_PORT:-8080}"
HELM_TIMEOUT="${HELM_TIMEOUT:-180s}"
WAIT_TIMEOUT="${WAIT_TIMEOUT:-180s}"
TEARDOWN_CLUSTER="${TEARDOWN_CLUSTER:-false}"
PF_PIDFILE="${PF_PIDFILE:-/tmp/terdut-demo-port-forward.pid}"
PF_LOGFILE="${PF_LOGFILE:-/tmp/terdut-demo-port-forward.log}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CHART_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)/charts/terdut-operator"
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
log() { printf '[run-demo] %s\n' "$*" >&2; }
die() { printf '[run-demo] FAILED: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<EOF
Usage: $0 [--teardown|--help]
Deploys (or tears down) the complete terdut demo on a kind cluster.
See the top of this file for every overridable environment variable.
EOF
}
# Only kills a port-forward THIS invocation started, so a successful run
# never has its background job reaped by its own exit trap.
STARTED_PF_PID=""
cleanup_on_failure() {
local rc=$?
if [ "$rc" -ne 0 ] && [ -n "$STARTED_PF_PID" ]; then
log "run failed -- stopping the port-forward it started (pid $STARTED_PF_PID)"
kill "$STARTED_PF_PID" 2>/dev/null || true
fi
exit "$rc"
}
trap cleanup_on_failure EXIT
# ---------------------------------------------------------------------------
# steps
# ---------------------------------------------------------------------------
preflight() {
local missing=()
for bin in kubectl kind helm jq curl; do
command -v "$bin" >/dev/null 2>&1 || missing+=("$bin")
done
if [ "${#missing[@]}" -gt 0 ]; then
die "missing required tools on PATH: ${missing[*]}"
fi
}
ensure_kind_cluster() {
case "$(kind get clusters 2>/dev/null)" in
*"$CLUSTER_NAME"*)
log "kind cluster '$CLUSTER_NAME' already exists, skipping creation" ;;
*)
log "creating kind cluster '$CLUSTER_NAME'"
kind create cluster --name "$CLUSTER_NAME" ;;
esac
kubectl config use-context "kind-${CLUSTER_NAME}" >/dev/null
}
install_operator() {
log "installing terdut-operator into namespace $OPERATOR_NAMESPACE"
helm upgrade --install "$RELEASE_NAME" "$CHART_DIR" \
--namespace "$OPERATOR_NAMESPACE" --create-namespace \
--wait --timeout "$HELM_TIMEOUT" \
|| die "helm install of terdut-operator did not become ready"
}
apply_demo() {
log "creating namespace $NAMESPACE"
kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
log "applying demo CRs (00-09) into $NAMESPACE"
kubectl apply -n "$NAMESPACE" -k "$SCRIPT_DIR" >/dev/null
}
wait_for_ready() {
local objects=(
"terdutserver/terdut-operator-demo"
"terdutteam/terdutteam-platform"
"terdutteam/terdutteam-payments"
"terdutescalationrule/terdutescalationrule-platform"
"terdutescalationrule/terdutescalationrule-payments"
"terdutdeadmanswitch/terdutdeadmanswitch-platform"
"terdutdeadmanswitch/terdutdeadmanswitch-payments"
"terdutalertsource/terdutalertsource-platform"
"terdutalertsource/terdutalertsource-payments"
)
local obj
for obj in "${objects[@]}"; do
log "waiting for $obj to become Ready"
kubectl wait --for=condition=Ready --timeout "$WAIT_TIMEOUT" -n "$NAMESPACE" "$obj" >/dev/null \
|| die "timed out waiting for $obj -- try: kubectl describe -n $NAMESPACE $obj"
done
}
start_port_forward() {
# A stale pidfile from an earlier run would otherwise collide with us on
# $LOCAL_PORT -- if that pid is still alive, stop it first.
if [ -f "$PF_PIDFILE" ]; then
local old_pid
old_pid="$(cat "$PF_PIDFILE" 2>/dev/null || true)"
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
log "stopping stale port-forward from a previous run (pid $old_pid)"
kill "$old_pid" 2>/dev/null || true
sleep 1
fi
rm -f "$PF_PIDFILE"
fi
log "starting port-forward svc/terdut-operator-demo ${LOCAL_PORT}:8080"
kubectl -n "$NAMESPACE" port-forward svc/terdut-operator-demo "${LOCAL_PORT}:8080" \
>"$PF_LOGFILE" 2>&1 &
STARTED_PF_PID=$!
echo "$STARTED_PF_PID" > "$PF_PIDFILE"
local tries=0
until curl -sf -o /dev/null "http://localhost:${LOCAL_PORT}/healthz"; do
tries=$((tries + 1))
if [ "$tries" -ge 30 ]; then
die "port-forward never became ready -- see $PF_LOGFILE"
fi
sleep 1
done
log "port-forward ready (pid $STARTED_PF_PID, log $PF_LOGFILE)"
}
# Redeems Platform's own invite link -- minted by its TerdutTeam
# (02-team-platform.yaml's spec.invite.enabled, reconciled through that
# team's own already-working team-scoped credential, which requireTeamOwner
# already treats as owner-equivalent for /invites -- ratified, not a
# workaround, in terdut-server's SERVICE-ACCOUNTS.md) and redeemed through
# the ordinary signup endpoint. Invite redemption bypasses signup_mode
# entirely (terdut-server's internal/api/signup.go), so this needs no admin
# credential, no signup_mode flip, and no direct Postgres access at all --
# unlike an earlier version of this script, before terdut-operator grew
# this feature (see niklas/terdut-server#23).
redeem_platform_invite() {
log "reading Platform's invite link"
local secret_name invite_url invite_token tries=0
until secret_name="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-platform \
-o jsonpath='{.status.inviteSecretRef.name}' 2>/dev/null)" && [ -n "$secret_name" ]; do
tries=$((tries + 1))
[ "$tries" -lt 30 ] || die "terdutteam-platform never reported status.inviteSecretRef -- check spec.invite.enabled and kubectl describe it"
sleep 1
done
invite_url="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.url}' | base64 -d)"
invite_token="${invite_url##*invite=}"
[ -n "$invite_token" ] || die "could not parse an invite token out of $invite_url"
log "signing up ${ALICE_USERNAME} via Platform's invite"
local body resp_file code
body="$(jq -n \
--arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" \
--arg p "$DEMO_PASSWORD" --arg i "$invite_token" \
'{username: $u, email: $e, password: $p, invite: $i}')"
resp_file="$(mktemp)"
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
-X POST "${BASE_URL}/api/signup" -H 'Content-Type: application/json' -d "$body")"
case "$code" in
201) log "created local account ${ALICE_USERNAME} (joined Platform)" ;;
409) log "account ${ALICE_USERNAME} already exists, skipping (re-run detected)" ;;
*) die "signup failed (HTTP $code): $(cat "$resp_file")" ;;
esac
rm -f "$resp_file"
}
# redeem_platform_invite's signup already used up alice's one signup -- a
# second POST /api/signup would just 409 on the taken username, it doesn't
# join an existing account to another team. Payments is joined through the
# ordinary team-scoped member endpoint instead, using that team's own
# already-working team-scoped credential (owner-equivalent, same reach the
# invite route above relies on) and alice's user id resolved via
# GET /api/users -- the same lookup tdclient.GetUserByUsername does
# operator-side. The endpoint upserts on (team_id, user_id), so this is
# naturally idempotent across re-runs with no separate conflict handling
# needed.
join_payments_team() {
log "adding ${ALICE_USERNAME} to Payments"
local payments_id payments_secret payments_key alice_id resp_file code
payments_id="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments -o jsonpath='{.status.teamID}')"
[ -n "$payments_id" ] || die "could not read status.teamID off terdutteam-payments"
payments_secret="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments \
-o jsonpath='{.status.credentialsSecretRef.name}')"
[ -n "$payments_secret" ] || die "terdutteam-payments has no status.credentialsSecretRef yet"
payments_key="$(kubectl -n "$OPERATOR_NAMESPACE" get secret "$payments_secret" -o jsonpath='{.data.token}' | base64 -d)"
alice_id="$(curl -sS -H "Authorization: Bearer $payments_key" "${BASE_URL}/api/users" \
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
[ -n "$alice_id" ] || die "could not resolve ${ALICE_USERNAME}'s user id via GET /api/users"
resp_file="$(mktemp)"
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
-X POST "${BASE_URL}/api/teams/${payments_id}/members" \
-H "Authorization: Bearer $payments_key" -H 'Content-Type: application/json' \
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "member"}')")"
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to Payments failed (HTTP $code): $(cat "$resp_file")"
log "added ${ALICE_USERNAME} to Payments"
rm -f "$resp_file"
}
fire_demo_alerts() {
log "firing representative demo alerts"
NAMESPACE="$NAMESPACE" BASE_URL="$BASE_URL" "$SCRIPT_DIR/fire-alerts.sh" platform high-cpu
NAMESPACE="$NAMESPACE" BASE_URL="$BASE_URL" "$SCRIPT_DIR/fire-alerts.sh" platform disk-full
NAMESPACE="$NAMESPACE" BASE_URL="$BASE_URL" "$SCRIPT_DIR/fire-alerts.sh" payments pod-crash
}
print_summary() {
cat <<EOF
terdut demo is up.
Web UI: http://localhost:${LOCAL_PORT}
Login: ${ALICE_USERNAME} / ${DEMO_PASSWORD}
Cluster: kind-${CLUSTER_NAME}
Namespace: ${NAMESPACE}
Port-forward pid: ${STARTED_PF_PID:-$(cat "$PF_PIDFILE" 2>/dev/null || echo unknown)} (log: ${PF_LOGFILE})
stop it with: kill \$(cat ${PF_PIDFILE})
Fire more alerts:
export NAMESPACE=${NAMESPACE} BASE_URL=${BASE_URL}
./fire-alerts.sh platform high-cpu
./fire-alerts.sh platform high-cpu resolve
./fire-alerts.sh payments heartbeat # send repeatedly (e.g. every minute)
# to keep a dead man's switch alive;
# stop sending it and, 15 minutes
# later, terdut-server opens a
# critical incident on its own.
Tear down:
$0 --teardown
# add TEARDOWN_CLUSTER=true to also delete the kind cluster itself
EOF
}
teardown() {
if [ -f "$PF_PIDFILE" ]; then
local pid
pid="$(cat "$PF_PIDFILE" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
log "stopping port-forward (pid $pid)"
kill "$pid" 2>/dev/null || true
fi
rm -f "$PF_PIDFILE"
fi
log "deleting namespace $NAMESPACE"
kubectl delete namespace "$NAMESPACE" --ignore-not-found --wait=true --timeout "$WAIT_TIMEOUT"
if [ "$TEARDOWN_CLUSTER" = "true" ]; then
log "deleting kind cluster $CLUSTER_NAME"
kind delete cluster --name "$CLUSTER_NAME"
else
log "leaving kind cluster '$CLUSTER_NAME' and the operator install in place" \
"(set TEARDOWN_CLUSTER=true to also delete the cluster)"
fi
}
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
main() {
case "${1:-}" in
--teardown)
preflight
teardown
trap - EXIT
exit 0
;;
--help|-h)
usage
trap - EXIT
exit 0
;;
"") ;;
*)
usage
die "unknown argument: $1"
;;
esac
preflight
ensure_kind_cluster
install_operator
apply_demo
wait_for_ready
start_port_forward
redeem_platform_invite
join_payments_team
fire_demo_alerts
print_summary
# Success: leave the port-forward running, don't let the EXIT trap kill it.
trap - EXIT
}
main "$@"
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"sync"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -35,6 +36,11 @@ import (
const (
fakeVersionString = "test"
errJSONKey = "error"
// deadmanSwitchesPath is the literal path (not Printf'd like the others
// below) shared by the exact-match collection route and the dispatcher
// that routes into it -- goconst flags three occurrences of the same
// string, so this is that string, once.
deadmanSwitchesPath = "/deadman/switches"
)
// fakeTerdutServer reproduces the exact stateful semantics of
@@ -83,6 +89,14 @@ type fakeTerdutServer struct {
nextIntegrationID int64
integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration
integrationDelete map[int64]bool // integrationID -> true once DELETEd, for 404-on-redelete
// invites/nextInviteID/inviteDelete back TerdutTeam's own invite-minting
// feature -- no unique constraint on an invite server-side either (every
// POST mints a brand new row, confirmed against source), same keyed-by-id
// shape as switches/integrations.
nextInviteID int64
invites map[int64]map[int64]tdclient.Invite // teamID -> inviteID -> invite
inviteDelete map[int64]bool // inviteID -> true once DELETEd, for 404-on-redelete
}
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
@@ -100,6 +114,9 @@ func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
integrations: map[int64]map[int64]tdclient.Integration{},
integrationDelete: map[int64]bool{},
invites: map[int64]map[int64]tdclient.Invite{},
inviteDelete: map[int64]bool{},
}
return f, httptest.NewServer(f)
}
@@ -265,7 +282,26 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
f.escalation[id] = req
w.WriteHeader(http.StatusNoContent)
case rest == "/deadman/switches" && r.Method == http.MethodGet:
case rest == deadmanSwitchesPath || strings.HasPrefix(rest, deadmanSwitchesPath+"/"):
f.handleDeadmanSubPath(w, r, id, rest)
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
f.handleIntegrationSubPath(w, r, id, rest)
case rest == "/invites" || strings.HasPrefix(rest, "/invites/"):
f.handleInviteSubPath(w, r, id, rest)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// handleDeadmanSubPath answers GET/POST /api/teams/{id}/deadman/switches and
// PUT/DELETE .../deadman/switches/{switchID} -- split out of
// handleTeamSubPath for the same gocyclo reason as handleIntegrationSubPath.
func (f *fakeTerdutServer) handleDeadmanSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
switch {
case rest == deadmanSwitchesPath && r.Method == http.MethodGet:
existing := f.switches[id]
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
for _, s := range existing {
@@ -273,7 +309,7 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
}
writeJSON(w, http.StatusOK, out)
case rest == "/deadman/switches" && r.Method == http.MethodPost:
case rest == deadmanSwitchesPath && r.Method == http.MethodPost:
var req deadmanSwitchFakeRequest
_ = json.NewDecoder(r.Body).Decode(&req)
f.nextSwitchID++
@@ -328,8 +364,48 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
f.switchDelete[switchID] = true
w.WriteHeader(http.StatusNoContent)
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
f.handleIntegrationSubPath(w, r, id, rest)
default:
w.WriteHeader(http.StatusNotFound)
}
}
// handleInviteSubPath answers POST /api/teams/{id}/invites and
// DELETE .../invites/{inviteID} -- split out for the same gocyclo reason as
// handleIntegrationSubPath.
func (f *fakeTerdutServer) handleInviteSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) {
switch {
case rest == "/invites" && r.Method == http.MethodPost:
var req struct {
Role string `json:"role"`
MaxUses int64 `json:"max_uses"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
f.nextInviteID++
inviteID := f.nextInviteID
inv := tdclient.Invite{
ID: inviteID, TeamID: id, Role: req.Role, MaxUses: req.MaxUses,
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
URL: fmt.Sprintf("https://terdut.example.invalid/signup?invite=invite-token-%d", inviteID),
}
if f.invites[id] == nil {
f.invites[id] = map[int64]tdclient.Invite{}
}
f.invites[id][inviteID] = inv
writeJSON(w, http.StatusCreated, inv)
case strings.HasPrefix(rest, "/invites/") && r.Method == http.MethodDelete:
inviteID, ok := parseTrailingID(rest, "/invites/")
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
if _, exists := f.invites[id][inviteID]; !exists {
w.WriteHeader(http.StatusNotFound)
return
}
delete(f.invites[id], inviteID)
f.inviteDelete[inviteID] = true
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
+16 -6
View File
@@ -37,17 +37,27 @@ func (r *TerdutServerReconciler) reconcileDeployment(
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
replicas := srv.Spec.Replicas
if replicas == 0 {
replicas = 1
// Only reachable for a TerdutServer stored before the
// +kubebuilder:default=2 marker existed -- the API server's own
// CRD defaulting fills this in for anything created or updated
// through it, so a fresh zero value here means a pre-existing
// object that predates the default, not a deliberate "none"
// (there is no way to request zero replicas).
replicas = 2
}
labels := labelsFor(srv)
deploy.Spec.Replicas = &replicas
deploy.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
// Recreate, not RollingUpdate: the sweeper and the notifier are
// unsynchronised singletons inside terdut-server, and two replicas
// overlapping during a rollout would both page for the same
// incident (matches the chart's own deployment.yaml comment).
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
// RollingUpdate, not Recreate: terdut-server v0.36.0 put the sweeper,
// the notifier and the migration runner each behind a Postgres
// advisory lock, and gave incident creation its own conflict
// resolution, so two replicas overlapping during a rollout no longer
// double-page, race a migration, or drop a webhook payload (matches
// the chart's own deployment.yaml comment). No explicit
// maxUnavailable/maxSurge: left at the 25%/25% default, which rounds
// to 0/1 at the default replicas: 2 -- already zero-downtime.
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RollingUpdateDeploymentStrategyType}
pod := srv.Spec.Pod
deploy.Spec.Template = corev1.PodTemplateSpec{
// pod.Annotations is assigned directly, not merged -- nothing
@@ -131,6 +131,9 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request)
if err := teamClient.SetTeamOIDCGroups(ctx, team.Status.TeamID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil {
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", team.Status.TeamID, err)
}
if err := r.reconcileInvite(ctx, &team, teamClient); err != nil {
return ctrl.Result{}, err
}
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"net/http/httptest"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -251,6 +252,88 @@ var _ = Describe("TerdutTeam Controller", func() {
})
})
Describe("spec.invite", func() {
It("mints a link into the TerdutTeam's own namespace, not the operator's", func(ctx SpecContext) {
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx) // finalizer
reconcileOnce(ctx) // create+mint+apply
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
team.Spec.Invite.Enabled = true
Expect(k8sClient.Update(ctx, team)).To(Succeed())
reconcileOnce(ctx)
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
Expect(team.Status.InviteSecretRef).NotTo(BeNil())
var secret corev1.Secret
Expect(k8sClient.Get(ctx, types.NamespacedName{
Name: team.Status.InviteSecretRef.Name, Namespace: team.Namespace,
}, &secret)).To(Succeed())
Expect(string(secret.Data[inviteSecretURLKey])).To(ContainSubstring("invite="))
Expect(string(secret.Data[inviteSecretInviteIDKey])).To(Equal("1"))
inv := fake.invites[team.Status.TeamID][1]
Expect(inv.Role).To(Equal("member"), "default role")
Expect(inv.MaxUses).To(Equal(int64(1)), "default max uses")
})
It("refreshes a link that's within a day of terdut-server's 7-day TTL", func(ctx SpecContext) {
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx)
reconcileOnce(ctx)
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
team.Spec.Invite.Enabled = true
Expect(k8sClient.Update(ctx, team)).To(Succeed())
reconcileOnce(ctx)
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
firstSecretName := team.Status.InviteSecretRef.Name
var secret corev1.Secret
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: firstSecretName, Namespace: team.Namespace}, &secret)).To(Succeed())
// Simulate the stored link being within the refresh window of
// expiry, the way it genuinely would be six days from now,
// without the test waiting six days.
secret.Data[inviteSecretExpiresAtKey] = []byte(time.Now().Add(12 * time.Hour).Format(time.RFC3339)) // inside inviteRefreshWindow
Expect(k8sClient.Update(ctx, &secret)).To(Succeed())
reconcileOnce(ctx)
Expect(fake.inviteDelete[1]).To(BeTrue(), "the stale invite should have been revoked")
Expect(fake.invites[team.Status.TeamID]).To(HaveKey(int64(2)), "a replacement should have been minted")
})
It("revokes the invite when spec.invite.enabled flips back to false", func(ctx SpecContext) {
createTeam(ctx, "platform", sameNSRef())
reconcileOnce(ctx)
reconcileOnce(ctx)
team := &terdutv1alpha1.TerdutTeam{}
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
team.Spec.Invite.Enabled = true
Expect(k8sClient.Update(ctx, team)).To(Succeed())
reconcileOnce(ctx)
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
secretName := team.Status.InviteSecretRef.Name
team.Spec.Invite.Enabled = false
Expect(k8sClient.Update(ctx, team)).To(Succeed())
reconcileOnce(ctx)
Expect(fake.inviteDelete[1]).To(BeTrue())
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
Expect(team.Status.InviteSecretRef).To(BeNil())
var secret corev1.Secret
err := k8sClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: team.Namespace}, &secret)
Expect(err).To(HaveOccurred(), "the invite Secret should have been deleted")
})
})
Describe("deletion", func() {
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
createTeam(ctx, "to-delete", sameNSRef())
+149
View File
@@ -0,0 +1,149 @@
package controller
import (
"context"
"fmt"
"strconv"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// Data keys inside the generated invite Secret, following the same naming
// shape as TerdutAlertSource's webhookSecret*Key constants.
const (
inviteSecretURLKey = "url"
inviteSecretInviteIDKey = "inviteID"
inviteSecretExpiresAtKey = "expiresAt"
)
// inviteRefreshWindow is how far ahead of expiry this controller mints a
// replacement link, so a human reading status.inviteSecretRef never finds a
// dead link mid-use. terdut-server's invite TTL is a fixed, unconfigurable
// 7 days (internal/api/signup.go's inviteTTL) -- refreshing a full day
// ahead of that leaves comfortable margin against this controller's own
// 5-minute resync interval ever being delayed.
const inviteRefreshWindow = 24 * time.Hour
func inviteSecretName(team *terdutv1alpha1.TerdutTeam) string {
return team.Name + "-terdut-invite"
}
// reconcileInvite applies spec.invite against teamClient -- this team's own
// team-scoped credential, already owner-equivalent for every /invites route
// (terdut-server's SERVICE-ACCOUNTS.md, ratified not accidental). Mints,
// refreshes ahead of expiry, or revokes, entirely independent of this
// team's own Ready condition: an invite is a convenience for onboarding a
// human, never something anything else in this reconcile waits on.
func (r *TerdutTeamReconciler) reconcileInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error {
if !team.Spec.Invite.Enabled {
return r.revokeInvite(ctx, team, teamClient)
}
secretName := inviteSecretName(team)
var secret corev1.Secret
err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: secretName}, &secret)
switch {
case err == nil:
expiresAt, parseErr := time.Parse(time.RFC3339, string(secret.Data[inviteSecretExpiresAtKey]))
if parseErr == nil && time.Until(expiresAt) > inviteRefreshWindow {
team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName}
return nil // still fresh, nothing to do this reconcile
}
// Expired, about to expire, or unreadable: mint a replacement.
// Revoke the old row by id first (best-effort) so a leaked old link
// stops working immediately rather than lingering unrevoked until
// its own TTL -- failure here is not fatal, since the replacement
// below is what actually matters.
if oldID, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil {
_ = teamClient.RevokeInvite(ctx, team.Status.TeamID, oldID)
}
return r.mintInvite(ctx, team, teamClient, secretName)
case apierrors.IsNotFound(err):
// Low stakes, unlike TerdutAlertSource's webhook URL: nothing
// external holds a durable dependency on one specific invite link
// staying stable the way an Alertmanager config depends on a
// webhook URL -- it's read once by one human and handed out. So
// this silently re-mints rather than failing closed the way
// TerdutAlertSource's ReasonWebhookSecretLost does for its Secret.
return r.mintInvite(ctx, team, teamClient, secretName)
default:
return err
}
}
func (r *TerdutTeamReconciler) mintInvite(
ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client, secretName string,
) error {
role := team.Spec.Invite.Role
if role == "" {
role = "member"
}
maxUses := team.Spec.Invite.MaxUses
if maxUses == 0 {
maxUses = 1
}
inv, err := teamClient.CreateInvite(ctx, team.Status.TeamID, role, maxUses)
if err != nil {
return fmt.Errorf("POST /api/teams/%d/invites: %w", team.Status.TeamID, err)
}
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: team.Namespace}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error {
secret.Data = map[string][]byte{
inviteSecretURLKey: []byte(inv.URL),
inviteSecretInviteIDKey: []byte(strconv.FormatInt(inv.ID, 10)),
inviteSecretExpiresAtKey: []byte(inv.ExpiresAt.Format(time.RFC3339)),
}
return controllerutil.SetControllerReference(team, secret, r.Scheme)
}); err != nil {
return err
}
team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName}
if r.Recorder != nil {
r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteMinted, terdutv1alpha1.ReasonInviteMinted,
"invite link minted into Secret %q", secretName)
}
return nil
}
// revokeInvite tears down spec.invite's Secret and server-side row when
// spec.invite.enabled is false (or was never set). Same-namespace and
// OwnerReference'd, so deleting the TerdutTeam itself already garbage-
// collects this Secret -- this path exists for the narrower case of
// flipping enabled back to false on an otherwise-live TerdutTeam.
func (r *TerdutTeamReconciler) revokeInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error {
if team.Status.InviteSecretRef == nil {
return nil
}
name := team.Status.InviteSecretRef.Name
var secret corev1.Secret
err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: name}, &secret)
switch {
case err == nil:
if id, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil {
if err := teamClient.RevokeInvite(ctx, team.Status.TeamID, id); err != nil {
return fmt.Errorf("DELETE /api/teams/%d/invites/%d: %w", team.Status.TeamID, id, err)
}
}
if err := r.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) {
return err
}
case !apierrors.IsNotFound(err):
return err
}
team.Status.InviteSecretRef = nil
if r.Recorder != nil {
r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteRevoked, terdutv1alpha1.ReasonInviteRevoked,
"invite link revoked")
}
return nil
}
+47
View File
@@ -566,3 +566,50 @@ func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID in
}
return c.do(req, nil)
}
// Invite is a standing link into a team (POST /api/teams/{teamID}/invites'
// own response shape). URL carries the raw token exactly once, at creation
// -- terdut-server never shows it again (same one-time-shown shape as an
// integration's webhook key) -- so a caller that needs it later has to have
// kept this response, not re-fetched it.
type Invite struct {
ID int64 `json:"id"`
TeamID int64 `json:"team_id"`
Role string `json:"role"`
ExpiresAt time.Time `json:"expires_at"`
MaxUses int64 `json:"max_uses"`
URL string `json:"url,omitempty"`
}
// CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated
// (requireTeamOwner), so c must hold this team's own team-scoped
// credential, which already satisfies that check via its synthetic owner
// membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling
// needed: unlike a team or a service account, an invite has no unique name
// to collide on -- every call mints a brand new row.
func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) {
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID),
map[string]any{"role": role, "max_uses": maxUses})
if err != nil {
return nil, err
}
var inv Invite
if err := c.do(req, &inv); err != nil {
return nil, err
}
return &inv, nil
}
// RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same
// credential requirement as CreateInvite. A 404 (already revoked, or never
// existed) is the caller's to treat as success if it wants to, the same way
// DeleteTeam's own 404 handling works -- this method itself just reports
// whatever terdut-server said.
func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error {
req, err := c.newRequest(ctx, http.MethodDelete,
fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil)
if err != nil {
return err
}
return c.do(req, nil)
}