Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d50248531c | |||
| 4007f54279 | |||
| 88172ade29 | |||
| 478ae6284a | |||
| fcc80b32d5 | |||
| 4aa4f17c42 | |||
| a0ea13955e | |||
| 2a08a8cd8e |
@@ -153,7 +153,7 @@ spec:
|
|||||||
image:
|
image:
|
||||||
repository: git.ryuvia.com/niklas/terdut-server
|
repository: git.ryuvia.com/niklas/terdut-server
|
||||||
tag: v0.9.3
|
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:
|
networking:
|
||||||
hostname: terdut.example.com
|
hostname: terdut.example.com
|
||||||
servicePort: 8080
|
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
|
CloudNativePG and the Zalando postgres-operator both expose the same
|
||||||
knobs. `affinity` is pure user-supplied passthrough, not a
|
knobs. `affinity` is pure user-supplied passthrough, not a
|
||||||
toggle-plus-generated-default the way a multi-replica-aware operator's
|
toggle-plus-generated-default the way a multi-replica-aware operator's
|
||||||
pod anti-affinity typically is: this operator never auto-generates
|
pod anti-affinity typically is: even though `replicas` now defaults to 2
|
||||||
affinity of its own, since `replicas` above 1 isn't a supported topology
|
(terdut-server v0.36.0's advisory locks made that safe, §4.1's own
|
||||||
(the sweeper/notifier singleton constraint, §4.1's own illustrative YAML
|
illustrative YAML comment), this operator still never auto-generates
|
||||||
comment). `spec.pod.disruptionBudget` is the one field here that isn't a
|
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
|
straight PodTemplateSpec knob — when set, the controller reconciles a
|
||||||
`PodDisruptionBudget` selecting this `TerdutServer`'s pods; clearing it
|
`PodDisruptionBudget` selecting this `TerdutServer`'s pods; clearing it
|
||||||
deletes any it previously created (§7). `minAvailable`/`maxUnavailable`
|
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.
|
- Automatic Deployment restart on upstream Postgres credential rotation.
|
||||||
- `spec.pod.priorityClassName`, pod-label passthrough beyond
|
- `spec.pod.priorityClassName`, pod-label passthrough beyond
|
||||||
`spec.pod.annotations`, and a HorizontalPodAutoscaler for `TerdutServer`
|
`spec.pod.annotations`, and a HorizontalPodAutoscaler for `TerdutServer`
|
||||||
— all considered alongside §4.1's `spec.pod` and explicitly left out of
|
— all considered alongside §4.1's `spec.pod` and left out of that round.
|
||||||
that round: an HPA in particular would actively contradict
|
An HPA no longer contradicts anything now that `spec.replicas` defaults
|
||||||
`spec.replicas`'s own stance that this operator doesn't support more
|
to 2 (terdut-server v0.36.0's advisory locks), but it is still a
|
||||||
than one replica (the sweeper/notifier singleton constraint).
|
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
|
- Admission webhooks / CEL-only validation limits (e.g. verifying a
|
||||||
`teamRef` exists at admission time rather than surfacing it as a status
|
`teamRef` exists at admission time rather than surfacing it as a status
|
||||||
condition after the fact).
|
condition after the fact).
|
||||||
|
|||||||
@@ -229,11 +229,11 @@ type PodSpec struct {
|
|||||||
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
|
Tolerations []corev1.Toleration `json:"tolerations,omitempty"`
|
||||||
|
|
||||||
// affinity covers node affinity, pod affinity and pod anti-affinity in
|
// affinity covers node affinity, pod affinity and pod anti-affinity in
|
||||||
// one field -- unlike a multi-replica-aware operator, this one never
|
// one field -- even though replicas now defaults to 2 (see
|
||||||
// generates a default anti-affinity itself (replicas above 1 isn't a
|
// TerdutServerSpec.Replicas's own doc comment), this operator still
|
||||||
// supported topology, see TerdutServerSpec.Replicas's own doc comment),
|
// never generates a default anti-affinity of its own the way a
|
||||||
// so this is pure user-supplied passthrough, not a toggle-plus-generated-
|
// multi-replica-aware operator typically would, so this stays pure
|
||||||
// default.
|
// user-supplied passthrough, not a toggle-plus-generated-default.
|
||||||
// +optional
|
// +optional
|
||||||
Affinity *corev1.Affinity `json:"affinity,omitempty"`
|
Affinity *corev1.Affinity `json:"affinity,omitempty"`
|
||||||
|
|
||||||
@@ -301,10 +301,14 @@ type TerdutServerSpec struct {
|
|||||||
// +required
|
// +required
|
||||||
Image ImageSpec `json:"image"`
|
Image ImageSpec `json:"image"`
|
||||||
|
|
||||||
// replicas. terdut-server is not horizontally-scale-tested; keep this
|
// replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
|
||||||
// at its default of 1 unless you've verified otherwise -- the sweeper
|
// notifier and the migration runner each behind a Postgres advisory
|
||||||
// and the notifier are unsynchronised singletons.
|
// lock, and gave incident creation its own conflict resolution, so
|
||||||
// +kubebuilder:default=1
|
// 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
|
// +optional
|
||||||
Replicas int32 `json:"replicas,omitempty"`
|
Replicas int32 `json:"replicas,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,42 @@ type TerdutTeamOIDC struct {
|
|||||||
OwnerGroup string `json:"ownerGroup,omitempty"`
|
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.
|
// TerdutTeamSpec defines the desired state of TerdutTeam.
|
||||||
type TerdutTeamSpec struct {
|
type TerdutTeamSpec struct {
|
||||||
// serverRef names the TerdutServer this team belongs to.
|
// serverRef names the TerdutServer this team belongs to.
|
||||||
@@ -43,6 +79,9 @@ type TerdutTeamSpec struct {
|
|||||||
|
|
||||||
// +optional
|
// +optional
|
||||||
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
Invite TerdutTeamInvite `json:"invite,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition reasons this controller sets.
|
// Condition reasons this controller sets.
|
||||||
@@ -62,6 +101,19 @@ const (
|
|||||||
ReasonTeamAdopted = "Adopted"
|
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.
|
// TerdutTeamStatus defines the observed state of TerdutTeam.
|
||||||
type TerdutTeamStatus struct {
|
type TerdutTeamStatus struct {
|
||||||
// +listType=map
|
// +listType=map
|
||||||
@@ -87,6 +139,17 @@ type TerdutTeamStatus struct {
|
|||||||
// +optional
|
// +optional
|
||||||
ServerEndpoint string `json:"serverEndpoint,omitempty"`
|
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
|
// +optional
|
||||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -834,6 +834,21 @@ func (in *TerdutTeam) DeepCopyObject() runtime.Object {
|
|||||||
return nil
|
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.
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -901,6 +916,7 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
|||||||
*out = *in
|
*out = *in
|
||||||
out.ServerRef = in.ServerRef
|
out.ServerRef = in.ServerRef
|
||||||
out.OIDC = in.OIDC
|
out.OIDC = in.OIDC
|
||||||
|
out.Invite = in.Invite
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
|
// 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 = new(SecretKeyRef)
|
||||||
**out = **in
|
**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.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ type: application
|
|||||||
# These fields decide nothing: `make helm-package` passes --version and
|
# These fields decide nothing: `make helm-package` passes --version and
|
||||||
# --app-version from the release tag (same reasoning as terdut-server's own
|
# --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.
|
# chart). They're for whoever reads the tree before a tag exists.
|
||||||
version: 0.2.0
|
version: 0.4.0
|
||||||
appVersion: "v0.2.0"
|
appVersion: "v0.4.0"
|
||||||
|
|
||||||
keywords:
|
keywords:
|
||||||
- kubernetes
|
- kubernetes
|
||||||
|
|||||||
@@ -327,11 +327,11 @@ spec:
|
|||||||
affinity:
|
affinity:
|
||||||
description: |-
|
description: |-
|
||||||
affinity covers node affinity, pod affinity and pod anti-affinity in
|
affinity covers node affinity, pod affinity and pod anti-affinity in
|
||||||
one field -- unlike a multi-replica-aware operator, this one never
|
one field -- even though replicas now defaults to 2 (see
|
||||||
generates a default anti-affinity itself (replicas above 1 isn't a
|
TerdutServerSpec.Replicas's own doc comment), this operator still
|
||||||
supported topology, see TerdutServerSpec.Replicas's own doc comment),
|
never generates a default anti-affinity of its own the way a
|
||||||
so this is pure user-supplied passthrough, not a toggle-plus-generated-
|
multi-replica-aware operator typically would, so this stays pure
|
||||||
default.
|
user-supplied passthrough, not a toggle-plus-generated-default.
|
||||||
properties:
|
properties:
|
||||||
nodeAffinity:
|
nodeAffinity:
|
||||||
description: Describes node affinity scheduling rules for
|
description: Describes node affinity scheduling rules for
|
||||||
@@ -4304,11 +4304,15 @@ spec:
|
|||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
replicas:
|
replicas:
|
||||||
default: 1
|
default: 2
|
||||||
description: |-
|
description: |-
|
||||||
replicas. terdut-server is not horizontally-scale-tested; keep this
|
replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
|
||||||
at its default of 1 unless you've verified otherwise -- the sweeper
|
notifier and the migration runner each behind a Postgres advisory
|
||||||
and the notifier are unsynchronised singletons.
|
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
|
format: int32
|
||||||
type: integer
|
type: integer
|
||||||
sweeper:
|
sweeper:
|
||||||
|
|||||||
@@ -63,6 +63,47 @@ spec:
|
|||||||
create rule, via TEAM-LOOKUP.md).
|
create rule, via TEAM-LOOKUP.md).
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
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:
|
oidc:
|
||||||
description: |-
|
description: |-
|
||||||
TerdutTeamOIDC binds which identity-provider groups grant membership and
|
TerdutTeamOIDC binds which identity-provider groups grant membership and
|
||||||
@@ -171,6 +212,24 @@ spec:
|
|||||||
- key
|
- key
|
||||||
- name
|
- name
|
||||||
type: object
|
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:
|
observedGeneration:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
|||||||
@@ -233,7 +233,11 @@ terdutServer:
|
|||||||
## Required when terdutServer.enabled.
|
## Required when terdutServer.enabled.
|
||||||
# tag: ""
|
# 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:
|
networking:
|
||||||
## Required when terdutServer.enabled -- terdut-server's own public
|
## Required when terdutServer.enabled -- terdut-server's own public
|
||||||
|
|||||||
@@ -324,11 +324,11 @@ spec:
|
|||||||
affinity:
|
affinity:
|
||||||
description: |-
|
description: |-
|
||||||
affinity covers node affinity, pod affinity and pod anti-affinity in
|
affinity covers node affinity, pod affinity and pod anti-affinity in
|
||||||
one field -- unlike a multi-replica-aware operator, this one never
|
one field -- even though replicas now defaults to 2 (see
|
||||||
generates a default anti-affinity itself (replicas above 1 isn't a
|
TerdutServerSpec.Replicas's own doc comment), this operator still
|
||||||
supported topology, see TerdutServerSpec.Replicas's own doc comment),
|
never generates a default anti-affinity of its own the way a
|
||||||
so this is pure user-supplied passthrough, not a toggle-plus-generated-
|
multi-replica-aware operator typically would, so this stays pure
|
||||||
default.
|
user-supplied passthrough, not a toggle-plus-generated-default.
|
||||||
properties:
|
properties:
|
||||||
nodeAffinity:
|
nodeAffinity:
|
||||||
description: Describes node affinity scheduling rules for
|
description: Describes node affinity scheduling rules for
|
||||||
@@ -4301,11 +4301,15 @@ spec:
|
|||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
replicas:
|
replicas:
|
||||||
default: 1
|
default: 2
|
||||||
description: |-
|
description: |-
|
||||||
replicas. terdut-server is not horizontally-scale-tested; keep this
|
replicas. Defaults to 2: terdut-server v0.36.0 put the sweeper, the
|
||||||
at its default of 1 unless you've verified otherwise -- the sweeper
|
notifier and the migration runner each behind a Postgres advisory
|
||||||
and the notifier are unsynchronised singletons.
|
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
|
format: int32
|
||||||
type: integer
|
type: integer
|
||||||
sweeper:
|
sweeper:
|
||||||
|
|||||||
@@ -60,6 +60,47 @@ spec:
|
|||||||
create rule, via TEAM-LOOKUP.md).
|
create rule, via TEAM-LOOKUP.md).
|
||||||
minLength: 1
|
minLength: 1
|
||||||
type: string
|
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:
|
oidc:
|
||||||
description: |-
|
description: |-
|
||||||
TerdutTeamOIDC binds which identity-provider groups grant membership and
|
TerdutTeamOIDC binds which identity-provider groups grant membership and
|
||||||
@@ -168,6 +209,24 @@ spec:
|
|||||||
- key
|
- key
|
||||||
- name
|
- name
|
||||||
type: object
|
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:
|
observedGeneration:
|
||||||
format: int64
|
format: int64
|
||||||
type: integer
|
type: integer
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
image:
|
image:
|
||||||
repository: git.ryuvia.com/niklas/terdut-server
|
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
|
replicas: 1
|
||||||
networking:
|
networking:
|
||||||
hostname: terdut-operator-demo.example
|
hostname: terdut-operator-demo.example
|
||||||
|
|||||||
@@ -15,3 +15,9 @@ spec:
|
|||||||
name: terdut-operator-demo
|
name: terdut-operator-demo
|
||||||
displayName: Platform
|
displayName: Platform
|
||||||
# No oidc block: this demo is password-login only (01-server.yaml).
|
# 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,3 +6,9 @@ spec:
|
|||||||
serverRef:
|
serverRef:
|
||||||
name: terdut-operator-demo
|
name: terdut-operator-demo
|
||||||
displayName: Payments
|
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
@@ -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
|
Postgres with `emptyDir` storage and a password committed in this
|
||||||
directory. Throw the whole namespace away when you're done.
|
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
|
## Prerequisites
|
||||||
|
|
||||||
- The operator and its CRDs installed and running (`make install
|
- 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
|
The operator's own bootstrap (DESIGN.md §6) creates the first user through
|
||||||
`/api/bootstrap` and immediately mints itself a service-account token from
|
`/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.
|
it, then discards the bootstrap user's own key — nobody ever signs in as
|
||||||
`signup_mode` also defaults to `invite_only`, so open signup needs turning
|
that account, and `signup_mode` stays `invite_only` by default. **Don't try
|
||||||
on first, using the admin token the operator generated for itself:
|
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
|
```sh
|
||||||
# Which namespace the operator itself runs in:
|
secretname=$(kubectl -n terdut-operator-demo get terdutteam terdutteam-platform \
|
||||||
kubectl get deploy -A -l control-plane=controller-manager
|
-o jsonpath='{.status.inviteSecretRef.name}')
|
||||||
|
url=$(kubectl -n terdut-operator-demo get secret "$secretname" -o jsonpath='{.data.url}' | base64 -d)
|
||||||
# The Secret holding the operator's own admin token for this TerdutServer
|
echo "$url" # open this, or POST /api/signup with {"invite": "<the token after invite=>", ...}
|
||||||
# (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"}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then sign up through the UI as a normal human account. `04-escalation-platform.yaml`
|
`04-escalation-platform.yaml` names a user `alice` at its first escalation
|
||||||
names a user `alice` at its first escalation level — sign up as `alice` if
|
level — sign up as `alice` if you want that level to mean something rather
|
||||||
you want that level to mean something rather than falling through to
|
than falling through to on-call after 5 minutes. `run-demo.sh` does exactly
|
||||||
on-call after 5 minutes.
|
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
|
## Fire some alerts
|
||||||
|
|
||||||
|
|||||||
Executable
+333
@@ -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"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
@@ -35,6 +36,11 @@ import (
|
|||||||
const (
|
const (
|
||||||
fakeVersionString = "test"
|
fakeVersionString = "test"
|
||||||
errJSONKey = "error"
|
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
|
// fakeTerdutServer reproduces the exact stateful semantics of
|
||||||
@@ -83,6 +89,14 @@ type fakeTerdutServer struct {
|
|||||||
nextIntegrationID int64
|
nextIntegrationID int64
|
||||||
integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration
|
integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration
|
||||||
integrationDelete map[int64]bool // integrationID -> true once DELETEd, for 404-on-redelete
|
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) {
|
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||||
@@ -100,6 +114,9 @@ func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
|||||||
|
|
||||||
integrations: map[int64]map[int64]tdclient.Integration{},
|
integrations: map[int64]map[int64]tdclient.Integration{},
|
||||||
integrationDelete: map[int64]bool{},
|
integrationDelete: map[int64]bool{},
|
||||||
|
|
||||||
|
invites: map[int64]map[int64]tdclient.Invite{},
|
||||||
|
inviteDelete: map[int64]bool{},
|
||||||
}
|
}
|
||||||
return f, httptest.NewServer(f)
|
return f, httptest.NewServer(f)
|
||||||
}
|
}
|
||||||
@@ -265,7 +282,26 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
|
|||||||
f.escalation[id] = req
|
f.escalation[id] = req
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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]
|
existing := f.switches[id]
|
||||||
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
|
out := make([]tdclient.DeadmanSwitch, 0, len(existing))
|
||||||
for _, s := range existing {
|
for _, s := range existing {
|
||||||
@@ -273,7 +309,7 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
|
||||||
case rest == "/deadman/switches" && r.Method == http.MethodPost:
|
case rest == deadmanSwitchesPath && r.Method == http.MethodPost:
|
||||||
var req deadmanSwitchFakeRequest
|
var req deadmanSwitchFakeRequest
|
||||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
f.nextSwitchID++
|
f.nextSwitchID++
|
||||||
@@ -328,8 +364,48 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ
|
|||||||
f.switchDelete[switchID] = true
|
f.switchDelete[switchID] = true
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
|
||||||
case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"):
|
default:
|
||||||
f.handleIntegrationSubPath(w, r, id, rest)
|
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:
|
default:
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
|||||||
@@ -37,17 +37,27 @@ func (r *TerdutServerReconciler) reconcileDeployment(
|
|||||||
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
|
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, deploy, func() error {
|
||||||
replicas := srv.Spec.Replicas
|
replicas := srv.Spec.Replicas
|
||||||
if replicas == 0 {
|
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)
|
labels := labelsFor(srv)
|
||||||
|
|
||||||
deploy.Spec.Replicas = &replicas
|
deploy.Spec.Replicas = &replicas
|
||||||
deploy.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
|
deploy.Spec.Selector = &metav1.LabelSelector{MatchLabels: labels}
|
||||||
// Recreate, not RollingUpdate: the sweeper and the notifier are
|
// RollingUpdate, not Recreate: terdut-server v0.36.0 put the sweeper,
|
||||||
// unsynchronised singletons inside terdut-server, and two replicas
|
// the notifier and the migration runner each behind a Postgres
|
||||||
// overlapping during a rollout would both page for the same
|
// advisory lock, and gave incident creation its own conflict
|
||||||
// incident (matches the chart's own deployment.yaml comment).
|
// resolution, so two replicas overlapping during a rollout no longer
|
||||||
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
|
// 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
|
pod := srv.Spec.Pod
|
||||||
deploy.Spec.Template = corev1.PodTemplateSpec{
|
deploy.Spec.Template = corev1.PodTemplateSpec{
|
||||||
// pod.Annotations is assigned directly, not merged -- nothing
|
// 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 {
|
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)
|
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{
|
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionReady,
|
Type: terdutv1alpha1.ConditionReady,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"time"
|
||||||
|
|
||||||
. "github.com/onsi/ginkgo/v2"
|
. "github.com/onsi/ginkgo/v2"
|
||||||
. "github.com/onsi/gomega"
|
. "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() {
|
Describe("deletion", func() {
|
||||||
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
|
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
|
||||||
createTeam(ctx, "to-delete", sameNSRef())
|
createTeam(ctx, "to-delete", sameNSRef())
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -566,3 +566,50 @@ func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID in
|
|||||||
}
|
}
|
||||||
return c.do(req, nil)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user