Let TerdutServer customize its pod, and never manage its own ingress
spec.pod (api/v1alpha1/terdutserver_types.go): annotations, nodeSelector,
tolerations, affinity, topologySpreadConstraints, resources, pod and
container securityContext, serviceAccountName, extraEnv/extraEnvFrom,
extraVolumes/extraVolumeMounts, imagePullSecrets, and an optional
disruptionBudget. All direct corev1 passthrough -- no wrapper types buy
anything for any of these, matching how CloudNativePG and the Zalando
postgres-operator both expose the same knobs, and matching this repo's
own SweeperSpec precedent ("wrap only when a round-trip through a
different type buys something"). affinity is pure user-supplied
passthrough, not a toggle-plus-generated-default the way a multi-replica
cluster operator's pod anti-affinity usually is: this operator never
auto-generates one, since spec.replicas above 1 isn't a supported
topology (the sweeper/notifier singleton constraint). Considered and
declined for this round: priorityClassName, pod labels beyond
annotations, and a HorizontalPodAutoscaler -- the last of those would
directly contradict the singleton constraint above.
disruptionBudget is the one field here that isn't a plain PodTemplateSpec
knob: when set, the controller now reconciles a PodDisruptionBudget
selecting the TerdutServer's own pods (new terdutserver_pdb.go); clearing
it deletes any it previously created. New RBAC marker on
poddisruptionbudgets to match.
Driven by a public-release pass: looking past this project's own use case
at what a mature, general-purpose operator CRD exposes here (researched
against Zalando postgres-operator and CloudNativePG specifically), not
just the fields this install happened to need.
Separately, and found while answering a question about exposing
TerdutServer through Istio instead of Gateway API: spec.networking's own
doc comment quietly promised a Gateway API HTTPRoute this operator would
build eventually ("a near-term follow-up, not deferred"). That promise is
wrong for a public release -- an operator managing someone's ingress
mechanism for them is a worse default than not touching it at all, and a
surprise HTTPRoute appearing once that follow-up eventually landed would
have been exactly backwards for an Istio (or plain-Ingress, or
intentionally-unexposed) install. Made the non-goal explicit and
permanent instead (DESIGN.md §1), removed the dead `gatewayListener`
field it was the only consumer of (zero runtime call sites anywhere --
setting it already had no effect, so this is a schema cleanup, not a
behavior change), and corrected ROADMAP.md's framing. hostname/servicePort
stay: both are live (TERDUT_PUBLIC_URL, container/Service port), this
operator just never acts on hostname for exposure. Added
examples/networking (Gateway API HTTPRoute, Istio VirtualService) showing
how to expose the plain ClusterIP Service the operator already creates --
outside the operator itself, as illustrations, not as something
examples/demo applies automatically.
No new terdut-server version requirement: both changes are CRD/controller-
only, nothing about the API this operator's bootstrap flow depends on
changed.
This commit is contained in:
@@ -50,6 +50,14 @@ it just created, never a server something else already bootstrapped first.
|
|||||||
that needs a live look at another object (e.g. "does this teamRef exist")
|
that needs a live look at another object (e.g. "does this teamRef exist")
|
||||||
is a status condition, not an admission rejection — keeps v1 to a
|
is a status condition, not an admission rejection — keeps v1 to a
|
||||||
controller-only deployment with no cert-manager/webhook dependency.
|
controller-only deployment with no cert-manager/webhook dependency.
|
||||||
|
- **Never manages external exposure/ingress for `TerdutServer`, in any
|
||||||
|
form — a permanent non-goal, not a staged one.** The operator creates a
|
||||||
|
plain `ClusterIP` Service (§4.1) and stops there: no `Ingress`, no
|
||||||
|
Gateway API `HTTPRoute`, no Istio `VirtualService`, nothing. Some
|
||||||
|
installs won't expose `TerdutServer` outside the cluster at all; others
|
||||||
|
will use whichever of those mechanisms already fits their cluster. That
|
||||||
|
choice belongs to whoever deploys it, not to this operator — see
|
||||||
|
`examples/networking` for worked (but not operator-managed) examples.
|
||||||
|
|
||||||
## 2. The README's open questions, resolved
|
## 2. The README's open questions, resolved
|
||||||
|
|
||||||
@@ -149,7 +157,6 @@ spec:
|
|||||||
networking:
|
networking:
|
||||||
hostname: terdut.example.com
|
hostname: terdut.example.com
|
||||||
servicePort: 8080
|
servicePort: 8080
|
||||||
gatewayListener: "" # same semantics as chart's networking.listener
|
|
||||||
database:
|
database:
|
||||||
dsn: "postgres://terdut@terdut-postgres:5432/terdut?sslmode=require" # mutually exclusive with postgresClusterRef
|
dsn: "postgres://terdut@terdut-postgres:5432/terdut?sslmode=require" # mutually exclusive with postgresClusterRef
|
||||||
passwordSecretRef: {name: terdut.terdut-postgres.credentials.postgresql.acid.zalan.do, key: password}
|
passwordSecretRef: {name: terdut.terdut-postgres.credentials.postgresql.acid.zalan.do, key: password}
|
||||||
@@ -188,6 +195,20 @@ spec:
|
|||||||
# selector: # required, and only meaningful, when from: Selector
|
# selector: # required, and only meaningful, when from: Selector
|
||||||
# matchLabels:
|
# matchLabels:
|
||||||
# terdut.ryuvia.com/allowed: "true"
|
# terdut.ryuvia.com/allowed: "true"
|
||||||
|
# Pod-level customization of the Deployment -- all optional, direct corev1
|
||||||
|
# passthrough throughout (see PodSpec's own doc comment). A representative
|
||||||
|
# subset:
|
||||||
|
pod:
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
limits: {memory: 256Mi}
|
||||||
|
tolerations:
|
||||||
|
- key: dedicated
|
||||||
|
operator: Equal
|
||||||
|
value: terdut
|
||||||
|
effect: NoSchedule
|
||||||
|
disruptionBudget:
|
||||||
|
minAvailable: 1 # mutually exclusive with maxUnavailable
|
||||||
status:
|
status:
|
||||||
conditions: [...] # Ready, DatabaseReady, Bootstrapped
|
conditions: [...] # Ready, DatabaseReady, Bootstrapped
|
||||||
observedGeneration: 3
|
observedGeneration: 3
|
||||||
@@ -214,6 +235,25 @@ per-name allowlist (no "and only these teams") — namespace-level consent is
|
|||||||
the right granularity here, same reasoning as `ListenerSet`: the namespace
|
the right granularity here, same reasoning as `ListenerSet`: the namespace
|
||||||
is the tenancy boundary, not the object.
|
is the tenancy boundary, not the object.
|
||||||
|
|
||||||
|
`spec.pod` is pod-level customization of the Deployment, all optional and
|
||||||
|
directly reusing corev1 types wherever corev1 already models the knob
|
||||||
|
exactly (`tolerations`, `affinity`, `topologySpreadConstraints`,
|
||||||
|
`resources`, `securityContext`/`containerSecurityContext`, `extraEnv`/
|
||||||
|
`extraEnvFrom`, `extraVolumes`/`extraVolumeMounts`, `imagePullSecrets`) —
|
||||||
|
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
|
||||||
|
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`
|
||||||
|
are mutually exclusive, `+kubebuilder:validation:XValidation`-guarded the
|
||||||
|
same way as `spec.database`'s own `dsn`/`postgresClusterRef` rule.
|
||||||
|
|
||||||
### 4.2 `TerdutTeam`
|
### 4.2 `TerdutTeam`
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -591,9 +631,15 @@ when nothing ever crosses into a tenant namespace in the first place.
|
|||||||
## 7. Ownership, status, garbage collection
|
## 7. Ownership, status, garbage collection
|
||||||
|
|
||||||
- Every generated object that lives in the *same* namespace as the CR that
|
- Every generated object that lives in the *same* namespace as the CR that
|
||||||
caused it (Deployment, Service, webhook Secret) carries a standard
|
caused it (Deployment, Service, webhook Secret, and `TerdutServer`'s own
|
||||||
`metav1.OwnerReference` — GC handles these, no finalizer needed. The two
|
PodDisruptionBudget) carries a standard `metav1.OwnerReference` — GC
|
||||||
credential Secrets from §6 are the one exception: they live in the
|
handles these, no finalizer needed. PodDisruptionBudget is the one
|
||||||
|
member of that list that's conditionally created/deleted rather than
|
||||||
|
always present: it exists only while `spec.pod.disruptionBudget` is set,
|
||||||
|
and the controller deletes it itself the moment that field is cleared
|
||||||
|
(it doesn't wait on GC for that case, only for the `TerdutServer` being
|
||||||
|
deleted outright). The two credential Secrets from §6 are the one
|
||||||
|
exception to OwnerReference-based cleanup generally: they live in the
|
||||||
operator's own namespace regardless of where their owning CR lives, so
|
operator's own namespace regardless of where their owning CR lives, so
|
||||||
`OwnerReference` doesn't apply (cross-namespace) and cleanup instead runs
|
`OwnerReference` doesn't apply (cross-namespace) and cleanup instead runs
|
||||||
through that CR's finalizer directly, alongside the server-side DELETE
|
through that CR's finalizer directly, alongside the server-side DELETE
|
||||||
@@ -640,10 +686,10 @@ documented and tested operationally:
|
|||||||
|
|
||||||
- The operator's own ServiceAccount needs, per namespace it's granted:
|
- The operator's own ServiceAccount needs, per namespace it's granted:
|
||||||
`get/list/watch/create/update/patch/delete` on `Deployments`, `Services`
|
`get/list/watch/create/update/patch/delete` on `Deployments`, `Services`
|
||||||
it owns, and `get/list/watch` on `postgresql.acid.zalan.do` (optional,
|
and `PodDisruptionBudgets` it owns, and `get/list/watch` on
|
||||||
degrade gracefully if absent per §8), plus cluster-wide `get/list` on
|
`postgresql.acid.zalan.do` (optional, degrade gracefully if absent per
|
||||||
`Namespace` (labels only, for `allowedTeams: {from: Selector}` evaluation
|
§8), plus cluster-wide `get/list` on `Namespace` (labels only, for
|
||||||
— §4.1, §4.6).
|
`allowedTeams: {from: Selector}` evaluation — §4.1, §4.6).
|
||||||
- **Two different `Secret` scopes, not one — corrected from an earlier draft
|
- **Two different `Secret` scopes, not one — corrected from an earlier draft
|
||||||
of this section.** That earlier draft said `Secret` access was "scoped to
|
of this section.** That earlier draft said `Secret` access was "scoped to
|
||||||
the operator's own namespace only... nowhere else," reasoning that with
|
the operator's own namespace only... nowhere else," reasoning that with
|
||||||
@@ -784,6 +830,12 @@ what it was, a separate install, until someone deletes it.
|
|||||||
a one-off exercise.
|
a one-off exercise.
|
||||||
- Gitops-managed team *membership* (see §4.2).
|
- Gitops-managed team *membership* (see §4.2).
|
||||||
- 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.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).
|
||||||
- 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).
|
||||||
|
|||||||
+10
-8
@@ -72,15 +72,17 @@ New commits build forward over the old ones; no git history rewrite.
|
|||||||
individual keys, so there's nothing to undo there regardless.
|
individual keys, so there's nothing to undo there regardless.
|
||||||
- RBAC: read-only watch on `postgresql.acid.zalan.do`, degrading gracefully
|
- RBAC: read-only watch on `postgresql.acid.zalan.do`, degrading gracefully
|
||||||
if that CRD isn't installed (§8, §9).
|
if that CRD isn't installed (§8, §9).
|
||||||
- Shipped, scoped down from §8's full ambition in two ways, both called out
|
- Shipped, scoped down from §8's full ambition in one way, called out in
|
||||||
in code rather than silently dropped: no live watch on the Zalando-
|
code rather than silently dropped: no live watch on the Zalando-
|
||||||
generated credentials Secret for rotation (relies on the periodic resync
|
generated credentials Secret for rotation (relies on the periodic resync
|
||||||
to notice eventually, higher latency than a watch); no Gateway API
|
to notice eventually, higher latency than a watch). A near-term
|
||||||
`HTTPRoute` creation from `spec.networking.hostname`/`gatewayListener`
|
follow-up, not deferred to a later stage.
|
||||||
(needs the Gateway API types as a new dependency, and nothing about
|
- External exposure (a Gateway API `HTTPRoute` from
|
||||||
proving a `TerdutServer` boots and bootstraps a real server depends on
|
`spec.networking.hostname`) was originally sketched here too, as a
|
||||||
external ingress existing). Both are near-term follow-ups, not deferred
|
second near-term follow-up alongside the one above. It's since become an
|
||||||
to a later stage.
|
explicit, permanent non-goal instead (DESIGN.md §1): the operator will
|
||||||
|
never manage ingress/exposure for `TerdutServer` in any form. See
|
||||||
|
`examples/networking` for how to do that yourself.
|
||||||
- `envtest` covering Deployment/Service reconciliation and both database
|
- `envtest` covering Deployment/Service reconciliation and both database
|
||||||
paths — the Zalando path needs that CRD's schema vendored into the test
|
paths — the Zalando path needs that CRD's schema vendored into the test
|
||||||
environment (there's no real `postgres-operator` controller in `envtest`,
|
environment (there's no real `postgres-operator` controller in `envtest`,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package v1alpha1
|
package v1alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecretKeyRef names one data key inside a Secret. Every use of this type in
|
// SecretKeyRef names one data key inside a Secret. Every use of this type in
|
||||||
@@ -29,33 +31,28 @@ type ImageSpec struct {
|
|||||||
Tag string `json:"tag"`
|
Tag string `json:"tag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NetworkingSpec is how this TerdutServer is reached from outside the
|
// NetworkingSpec configures terdut-server itself and the plain ClusterIP
|
||||||
// cluster.
|
// Service the operator creates in front of it. It does not expose
|
||||||
//
|
// TerdutServer outside the cluster in any way, and never will (DESIGN.md
|
||||||
// hostname/gatewayListener describe the intended Gateway API HTTPRoute
|
// §1 -- a permanent non-goal, not a staged one): exposing it is entirely up
|
||||||
// (matching charts/terdut-server's own templates/httpproxy.yaml, despite its
|
// to whoever deploys it -- a Gateway API HTTPRoute, a plain Ingress, an
|
||||||
// name — that chart carries a Gateway API HTTPRoute, not a Contour
|
// Istio VirtualService, or nothing at all if it should stay cluster-
|
||||||
// HTTPProxy), but creating that HTTPRoute isn't implemented yet: it needs
|
// internal. See examples/networking for worked examples against the
|
||||||
// the Gateway API types as a new dependency, and nothing about proving a
|
// Service this creates.
|
||||||
// TerdutServer boots and bootstraps a real server depends on external
|
|
||||||
// ingress existing. Tracked as a near-term follow-up, not deferred to a
|
|
||||||
// later ROADMAP.md stage the way Deployment/database/bootstrap once were.
|
|
||||||
type NetworkingSpec struct {
|
type NetworkingSpec struct {
|
||||||
// hostname the HTTPRoute will carry once it exists.
|
// hostname is terdut-server's own public URL (TERDUT_PUBLIC_URL) --
|
||||||
|
// used for absolute links terdut-server generates itself
|
||||||
|
// (notifications, OIDC redirect URIs), not read by this operator for
|
||||||
|
// anything ingress-related. Set it to whatever hostname your own
|
||||||
|
// exposure mechanism, if any, actually serves this on.
|
||||||
// +optional
|
// +optional
|
||||||
Hostname string `json:"hostname,omitempty"`
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
|
||||||
// servicePort is both the Service's port and the HTTPRoute's backend
|
// servicePort is both the container's port and the ClusterIP Service's
|
||||||
// port once it exists. Defaults to 8080, matching the chart's own
|
// port. Defaults to 8080, matching the chart's own service.port default.
|
||||||
// service.port default.
|
|
||||||
// +kubebuilder:default=8080
|
// +kubebuilder:default=8080
|
||||||
// +optional
|
// +optional
|
||||||
ServicePort int32 `json:"servicePort,omitempty"`
|
ServicePort int32 `json:"servicePort,omitempty"`
|
||||||
|
|
||||||
// gatewayListener is the HTTPRoute's sectionName once it exists. Empty
|
|
||||||
// attaches to every matching listener, including plaintext HTTP.
|
|
||||||
// +optional
|
|
||||||
GatewayListener string `json:"gatewayListener,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgresClusterRef names a Zalando postgres-operator `postgresql` CR
|
// PostgresClusterRef names a Zalando postgres-operator `postgresql` CR
|
||||||
@@ -190,6 +187,109 @@ type AllowedTeams struct {
|
|||||||
Namespaces AllowedTeamsNamespaces `json:"namespaces,omitempty"`
|
Namespaces AllowedTeamsNamespaces `json:"namespaces,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PodDisruptionBudgetSpec configures an optional PodDisruptionBudget for
|
||||||
|
// this TerdutServer's Deployment. Exactly one of minAvailable or
|
||||||
|
// maxUnavailable may be set, matching policyv1.PodDisruptionBudgetSpec's own
|
||||||
|
// upstream rule (both wrap intstr.IntOrString unchanged here -- this is pure
|
||||||
|
// passthrough, not reshaped) and mirroring DatabaseSpec's own
|
||||||
|
// dsn/postgresClusterRef mutual-exclusion pattern. Clearing this field
|
||||||
|
// deletes any PodDisruptionBudget the controller previously created for this
|
||||||
|
// TerdutServer (DESIGN.md §7).
|
||||||
|
// +kubebuilder:validation:XValidation:rule="(has(self.minAvailable) ? 1 : 0) + (has(self.maxUnavailable) ? 1 : 0) == 1",message="exactly one of minAvailable or maxUnavailable must be set"
|
||||||
|
type PodDisruptionBudgetSpec struct {
|
||||||
|
// minAvailable -- mutually exclusive with maxUnavailable.
|
||||||
|
// +optional
|
||||||
|
MinAvailable *intstr.IntOrString `json:"minAvailable,omitempty"`
|
||||||
|
|
||||||
|
// maxUnavailable -- mutually exclusive with minAvailable.
|
||||||
|
// +optional
|
||||||
|
MaxUnavailable *intstr.IntOrString `json:"maxUnavailable,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PodSpec is pod-level customization of the Deployment this TerdutServer
|
||||||
|
// creates. Fields here directly reuse corev1 types wherever corev1 already
|
||||||
|
// models the knob exactly, rather than wrapping (unlike SecretKeyRef's own
|
||||||
|
// "wrap only when a round-trip through a different type buys something"
|
||||||
|
// standard would suggest at first glance -- none of these do: Tolerations,
|
||||||
|
// Affinity, TopologySpreadConstraints, Resources, SecurityContext, EnvVar,
|
||||||
|
// EnvFromSource, Volume, VolumeMount and LocalObjectReference are all passed
|
||||||
|
// straight through to the pod template with no added semantics, matching how
|
||||||
|
// CloudNativePG and the Zalando postgres-operator both expose the same
|
||||||
|
// knobs).
|
||||||
|
type PodSpec struct {
|
||||||
|
// annotations are merged onto the pod template's own metadata.
|
||||||
|
// Operator-managed labels (labelsFor) are never touched by this field.
|
||||||
|
// +optional
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
NodeSelector map[string]string `json:"nodeSelector,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
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.
|
||||||
|
// +optional
|
||||||
|
Affinity *corev1.Affinity `json:"affinity,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"`
|
||||||
|
|
||||||
|
// resources applied to the main terdut-server container. Unset today --
|
||||||
|
// this field closes a pre-existing gap, not a behavior change for
|
||||||
|
// anyone not setting it.
|
||||||
|
// +optional
|
||||||
|
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
||||||
|
|
||||||
|
// securityContext is pod-level.
|
||||||
|
// +optional
|
||||||
|
SecurityContext *corev1.PodSecurityContext `json:"securityContext,omitempty"`
|
||||||
|
|
||||||
|
// containerSecurityContext applies to the main terdut-server container
|
||||||
|
// only -- not wait-for-postgres, which runs a stock postgres image this
|
||||||
|
// operator doesn't control the entrypoint of. No implicit defaults are
|
||||||
|
// merged underneath it.
|
||||||
|
// +optional
|
||||||
|
ContainerSecurityContext *corev1.SecurityContext `json:"containerSecurityContext,omitempty"`
|
||||||
|
|
||||||
|
// serviceAccountName. Defaults to "default", same as any pod that
|
||||||
|
// doesn't set it.
|
||||||
|
// +optional
|
||||||
|
ServiceAccountName string `json:"serviceAccountName,omitempty"`
|
||||||
|
|
||||||
|
// extraEnv is appended after the fixed env vars buildEnv produces.
|
||||||
|
// +optional
|
||||||
|
ExtraEnv []corev1.EnvVar `json:"extraEnv,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
ExtraEnvFrom []corev1.EnvFromSource `json:"extraEnvFrom,omitempty"`
|
||||||
|
|
||||||
|
// extraVolumes are added to the pod spec; pair with extraVolumeMounts to
|
||||||
|
// actually mount one on the main container.
|
||||||
|
// +optional
|
||||||
|
ExtraVolumes []corev1.Volume `json:"extraVolumes,omitempty"`
|
||||||
|
|
||||||
|
// extraVolumeMounts are added to the main terdut-server container only
|
||||||
|
// -- not wait-for-postgres.
|
||||||
|
// +optional
|
||||||
|
ExtraVolumeMounts []corev1.VolumeMount `json:"extraVolumeMounts,omitempty"`
|
||||||
|
|
||||||
|
// +optional
|
||||||
|
ImagePullSecrets []corev1.LocalObjectReference `json:"imagePullSecrets,omitempty"`
|
||||||
|
|
||||||
|
// disruptionBudget, when set, causes the controller to reconcile a
|
||||||
|
// policyv1.PodDisruptionBudget selecting this TerdutServer's pods.
|
||||||
|
// Removing this field deletes any PodDisruptionBudget the controller
|
||||||
|
// previously created.
|
||||||
|
// +optional
|
||||||
|
DisruptionBudget *PodDisruptionBudgetSpec `json:"disruptionBudget,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// TerdutServerSpec defines the desired state of TerdutServer.
|
// TerdutServerSpec defines the desired state of TerdutServer.
|
||||||
//
|
//
|
||||||
// The operator creates and owns every TerdutServer it manages (DESIGN.md
|
// The operator creates and owns every TerdutServer it manages (DESIGN.md
|
||||||
@@ -237,6 +337,11 @@ type TerdutServerSpec struct {
|
|||||||
// this CRD's schema doesn't need a breaking change to grow it later.
|
// this CRD's schema doesn't need a breaking change to grow it later.
|
||||||
// +optional
|
// +optional
|
||||||
AllowedTeams AllowedTeams `json:"allowedTeams,omitempty"`
|
AllowedTeams AllowedTeams `json:"allowedTeams,omitempty"`
|
||||||
|
|
||||||
|
// pod is pod-level customization of the Deployment this TerdutServer
|
||||||
|
// creates (DESIGN.md §4.1).
|
||||||
|
// +optional
|
||||||
|
Pod PodSpec `json:"pod,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Condition types this controller sets on TerdutServer.
|
// Condition types this controller sets on TerdutServer.
|
||||||
|
|||||||
@@ -5,8 +5,10 @@
|
|||||||
package v1alpha1
|
package v1alpha1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
"k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -210,6 +212,128 @@ func (in *OIDCSpec) DeepCopy() *OIDCSpec {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *PodDisruptionBudgetSpec) DeepCopyInto(out *PodDisruptionBudgetSpec) {
|
||||||
|
*out = *in
|
||||||
|
if in.MinAvailable != nil {
|
||||||
|
in, out := &in.MinAvailable, &out.MinAvailable
|
||||||
|
*out = new(intstr.IntOrString)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
|
if in.MaxUnavailable != nil {
|
||||||
|
in, out := &in.MaxUnavailable, &out.MaxUnavailable
|
||||||
|
*out = new(intstr.IntOrString)
|
||||||
|
**out = **in
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodDisruptionBudgetSpec.
|
||||||
|
func (in *PodDisruptionBudgetSpec) DeepCopy() *PodDisruptionBudgetSpec {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(PodDisruptionBudgetSpec)
|
||||||
|
in.DeepCopyInto(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||||
|
func (in *PodSpec) DeepCopyInto(out *PodSpec) {
|
||||||
|
*out = *in
|
||||||
|
if in.Annotations != nil {
|
||||||
|
in, out := &in.Annotations, &out.Annotations
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.NodeSelector != nil {
|
||||||
|
in, out := &in.NodeSelector, &out.NodeSelector
|
||||||
|
*out = make(map[string]string, len(*in))
|
||||||
|
for key, val := range *in {
|
||||||
|
(*out)[key] = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Tolerations != nil {
|
||||||
|
in, out := &in.Tolerations, &out.Tolerations
|
||||||
|
*out = make([]corev1.Toleration, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.Affinity != nil {
|
||||||
|
in, out := &in.Affinity, &out.Affinity
|
||||||
|
*out = new(corev1.Affinity)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.TopologySpreadConstraints != nil {
|
||||||
|
in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints
|
||||||
|
*out = make([]corev1.TopologySpreadConstraint, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in.Resources.DeepCopyInto(&out.Resources)
|
||||||
|
if in.SecurityContext != nil {
|
||||||
|
in, out := &in.SecurityContext, &out.SecurityContext
|
||||||
|
*out = new(corev1.PodSecurityContext)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.ContainerSecurityContext != nil {
|
||||||
|
in, out := &in.ContainerSecurityContext, &out.ContainerSecurityContext
|
||||||
|
*out = new(corev1.SecurityContext)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.ExtraEnv != nil {
|
||||||
|
in, out := &in.ExtraEnv, &out.ExtraEnv
|
||||||
|
*out = make([]corev1.EnvVar, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ExtraEnvFrom != nil {
|
||||||
|
in, out := &in.ExtraEnvFrom, &out.ExtraEnvFrom
|
||||||
|
*out = make([]corev1.EnvFromSource, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ExtraVolumes != nil {
|
||||||
|
in, out := &in.ExtraVolumes, &out.ExtraVolumes
|
||||||
|
*out = make([]corev1.Volume, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ExtraVolumeMounts != nil {
|
||||||
|
in, out := &in.ExtraVolumeMounts, &out.ExtraVolumeMounts
|
||||||
|
*out = make([]corev1.VolumeMount, len(*in))
|
||||||
|
for i := range *in {
|
||||||
|
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.ImagePullSecrets != nil {
|
||||||
|
in, out := &in.ImagePullSecrets, &out.ImagePullSecrets
|
||||||
|
*out = make([]corev1.LocalObjectReference, len(*in))
|
||||||
|
copy(*out, *in)
|
||||||
|
}
|
||||||
|
if in.DisruptionBudget != nil {
|
||||||
|
in, out := &in.DisruptionBudget, &out.DisruptionBudget
|
||||||
|
*out = new(PodDisruptionBudgetSpec)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PodSpec.
|
||||||
|
func (in *PodSpec) DeepCopy() *PodSpec {
|
||||||
|
if in == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := new(PodSpec)
|
||||||
|
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 *PostgresClusterRef) DeepCopyInto(out *PostgresClusterRef) {
|
func (in *PostgresClusterRef) DeepCopyInto(out *PostgresClusterRef) {
|
||||||
*out = *in
|
*out = *in
|
||||||
@@ -643,6 +767,7 @@ func (in *TerdutServerSpec) DeepCopyInto(out *TerdutServerSpec) {
|
|||||||
in.Notify.DeepCopyInto(&out.Notify)
|
in.Notify.DeepCopyInto(&out.Notify)
|
||||||
in.OIDC.DeepCopyInto(&out.OIDC)
|
in.OIDC.DeepCopyInto(&out.OIDC)
|
||||||
in.AllowedTeams.DeepCopyInto(&out.AllowedTeams)
|
in.AllowedTeams.DeepCopyInto(&out.AllowedTeams)
|
||||||
|
in.Pod.DeepCopyInto(&out.Pod)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerSpec.
|
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutServerSpec.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,18 @@ rules:
|
|||||||
verbs:
|
verbs:
|
||||||
- create
|
- create
|
||||||
- patch
|
- patch
|
||||||
|
- apiGroups:
|
||||||
|
- policy
|
||||||
|
resources:
|
||||||
|
- poddisruptionbudgets
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -51,4 +51,8 @@ spec:
|
|||||||
allowedTeams:
|
allowedTeams:
|
||||||
{{- toYaml . | nindent 4 }}
|
{{- toYaml . | nindent 4 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
{{- with .Values.terdutServer.pod }}
|
||||||
|
pod:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -236,9 +236,12 @@ terdutServer:
|
|||||||
replicas: 1
|
replicas: 1
|
||||||
|
|
||||||
networking:
|
networking:
|
||||||
## Required when terdutServer.enabled -- the hostname a future
|
## Required when terdutServer.enabled -- terdut-server's own public
|
||||||
## HTTPRoute will carry (see NetworkingSpec's own doc comment: creating
|
## URL, used for absolute links it generates itself (notifications,
|
||||||
## that HTTPRoute isn't implemented yet).
|
## OIDC redirect URIs). This chart/operator never creates any
|
||||||
|
## ingress/HTTPRoute for it -- see examples/networking in the repo for
|
||||||
|
## how to expose the Service this chart's TerdutServer CR causes to
|
||||||
|
## be created, if you want to expose it at all.
|
||||||
# hostname: ""
|
# hostname: ""
|
||||||
servicePort: 8080
|
servicePort: 8080
|
||||||
|
|
||||||
@@ -270,3 +273,19 @@ terdutServer:
|
|||||||
|
|
||||||
# allowedTeams: {}
|
# allowedTeams: {}
|
||||||
|
|
||||||
|
## Pod-level customization of the Deployment this TerdutServer creates --
|
||||||
|
## see api/v1alpha1/terdutserver_types.go's PodSpec for the full shape
|
||||||
|
## (nodeSelector, tolerations, affinity, topologySpreadConstraints,
|
||||||
|
## securityContext, containerSecurityContext, serviceAccountName,
|
||||||
|
## extraEnv/extraEnvFrom, extraVolumes/extraVolumeMounts,
|
||||||
|
## imagePullSecrets, disruptionBudget). All optional; resources is the
|
||||||
|
## one most installs will want to set, since the container otherwise
|
||||||
|
## runs with no requests/limits at all:
|
||||||
|
# pod:
|
||||||
|
# resources:
|
||||||
|
# requests:
|
||||||
|
# cpu: 100m
|
||||||
|
# memory: 128Mi
|
||||||
|
# limits:
|
||||||
|
# memory: 256Mi
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,18 @@ rules:
|
|||||||
verbs:
|
verbs:
|
||||||
- create
|
- create
|
||||||
- patch
|
- patch
|
||||||
|
- apiGroups:
|
||||||
|
- policy
|
||||||
|
resources:
|
||||||
|
- poddisruptionbudgets
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- patch
|
||||||
|
- update
|
||||||
|
- watch
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- terdut.ryuvia.com
|
- terdut.ryuvia.com
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -31,3 +31,12 @@ spec:
|
|||||||
timeout: 15m
|
timeout: 15m
|
||||||
severity: critical
|
severity: critical
|
||||||
passwordLogin: true
|
passwordLogin: true
|
||||||
|
# Pod-level customization, all optional -- see PodSpec in
|
||||||
|
# api/v1alpha1/terdutserver_types.go for the full shape (tolerations,
|
||||||
|
# affinity, topologySpreadConstraints, securityContext,
|
||||||
|
# serviceAccountName, extraEnv/extraVolumes, imagePullSecrets,
|
||||||
|
# disruptionBudget, ...). Example:
|
||||||
|
# pod:
|
||||||
|
# resources:
|
||||||
|
# requests: {cpu: 100m, memory: 128Mi}
|
||||||
|
# limits: {memory: 256Mi}
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
# this directory (teams, escalation rules, dead man's switches, alert
|
# this directory (teams, escalation rules, dead man's switches, alert
|
||||||
# sources) references it by name.
|
# sources) references it by name.
|
||||||
#
|
#
|
||||||
# networking.hostname is accepted but not yet acted on: creating the
|
# The operator never creates any ingress/HTTPRoute for this TerdutServer --
|
||||||
# HTTPRoute for it isn't implemented yet (api/v1alpha1/terdutserver_types.go,
|
# that's a permanent non-goal (DESIGN.md §1, NetworkingSpec's own doc
|
||||||
# NetworkingSpec's own doc comment) -- this TerdutServer is reachable from
|
# comment), not a missing feature. This demo reaches it only by
|
||||||
# outside the cluster only by port-forwarding its Service, same name as
|
# port-forwarding its Service, same name as this object (see README.md);
|
||||||
# this object (see README.md).
|
# see ../networking for worked examples of exposing it yourself instead.
|
||||||
apiVersion: terdut.ryuvia.com/v1alpha1
|
apiVersion: terdut.ryuvia.com/v1alpha1
|
||||||
kind: TerdutServer
|
kind: TerdutServer
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -59,15 +59,16 @@ of it should settle within a reconcile interval or two.
|
|||||||
|
|
||||||
## See the web UI
|
## See the web UI
|
||||||
|
|
||||||
The operator doesn't create any external exposure yet
|
The operator never creates any external exposure for a `TerdutServer` --
|
||||||
(`NetworkingSpec`'s own doc comment in `api/v1alpha1/terdutserver_types.go`
|
that's a permanent non-goal (DESIGN.md §1), not a missing feature, so this
|
||||||
— `spec.networking.hostname` is accepted but nothing acts on it), so:
|
demo just reaches it the simplest way there is:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kubectl -n terdut-operator-demo port-forward svc/terdut-operator-demo 8080:8080
|
kubectl -n terdut-operator-demo port-forward svc/terdut-operator-demo 8080:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
and open http://localhost:8080.
|
and open http://localhost:8080. See `../networking` for worked examples of
|
||||||
|
exposing it for real (Gateway API, Istio, or a plain `Ingress`) instead.
|
||||||
|
|
||||||
### First login
|
### First login
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Exposing a TerdutServer
|
||||||
|
|
||||||
|
This operator never manages external exposure/ingress for `TerdutServer`,
|
||||||
|
in any form — a permanent non-goal (`DESIGN.md` §1), not a missing
|
||||||
|
feature. Some installs won't expose it outside the cluster at all (see
|
||||||
|
`examples/demo`, which just port-forwards); others will put it behind
|
||||||
|
whatever their cluster already uses. That choice is entirely yours, not
|
||||||
|
the operator's.
|
||||||
|
|
||||||
|
The only contract the operator gives you to build on: a plain `ClusterIP`
|
||||||
|
Service, named after the `TerdutServer` CR (same name, same namespace),
|
||||||
|
with a port named `http` (`spec.networking.servicePort`, default `8080`).
|
||||||
|
Everything here targets exactly that Service — none of it is applied by
|
||||||
|
`examples/demo`'s `kustomization.yaml`, and none of it depends on anything
|
||||||
|
the operator creates beyond that one Service.
|
||||||
|
|
||||||
|
Pick whichever matches your cluster:
|
||||||
|
|
||||||
|
- **`httproute.yaml`** — a [Gateway API](https://gateway-api.sigs.k8s.io/)
|
||||||
|
`HTTPRoute`, attached to a `Gateway` you already have.
|
||||||
|
- **`istio-virtualservice.yaml`** — an Istio `VirtualService`, attached to
|
||||||
|
a `Gateway` (Istio's own CRD, not Gateway API's) you already have.
|
||||||
|
- **A plain `Ingress`** needs no example here — it's the same idea with
|
||||||
|
one fewer layer of indirection: an `Ingress` with a single rule whose
|
||||||
|
`backend.service.name`/`port.name` point at the `TerdutServer`'s Service
|
||||||
|
and `http` port.
|
||||||
|
|
||||||
|
Remember to set `spec.networking.hostname` on the `TerdutServer` itself to
|
||||||
|
whatever hostname you expose it on — that's not read by the operator for
|
||||||
|
any of this, but terdut-server uses it for its own absolute links
|
||||||
|
(notifications, OIDC redirect URIs).
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Gateway API HTTPRoute exposing a TerdutServer through a Gateway you
|
||||||
|
# already have (not something this operator creates or watches -- see
|
||||||
|
# ../networking/README.md). Replace terdut-operator-demo and the Gateway
|
||||||
|
# reference/hostname with your own; terdut-operator-demo matches
|
||||||
|
# ../demo/01-server.yaml, if you're layering this onto that demo.
|
||||||
|
apiVersion: gateway.networking.k8s.io/v1
|
||||||
|
kind: HTTPRoute
|
||||||
|
metadata:
|
||||||
|
name: terdut-operator-demo
|
||||||
|
namespace: terdut-operator-demo
|
||||||
|
spec:
|
||||||
|
parentRefs:
|
||||||
|
- name: my-gateway # an existing Gateway in this namespace (or
|
||||||
|
# namespace: gateway-ns # a different one, if the Gateway allows it)
|
||||||
|
sectionName: https # the listener to attach to, if it's picky
|
||||||
|
hostnames:
|
||||||
|
- terdut-operator-demo.example # matches networking.hostname on the CR
|
||||||
|
rules:
|
||||||
|
- backendRefs:
|
||||||
|
- name: terdut-operator-demo # the Service the operator created --
|
||||||
|
port: 8080 # same name as the TerdutServer CR
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Istio VirtualService exposing a TerdutServer through an Istio Gateway
|
||||||
|
# you already have (Istio's own Gateway CRD, not Gateway API's -- not
|
||||||
|
# something this operator creates or watches, see ../networking/README.md).
|
||||||
|
# Replace terdut-operator-demo and the gateway reference/hostname with your
|
||||||
|
# own; terdut-operator-demo matches ../demo/01-server.yaml, if you're
|
||||||
|
# layering this onto that demo.
|
||||||
|
apiVersion: networking.istio.io/v1
|
||||||
|
kind: VirtualService
|
||||||
|
metadata:
|
||||||
|
name: terdut-operator-demo
|
||||||
|
namespace: terdut-operator-demo
|
||||||
|
spec:
|
||||||
|
hosts:
|
||||||
|
- terdut-operator-demo.example # matches networking.hostname on the CR
|
||||||
|
gateways:
|
||||||
|
- my-gateway-namespace/my-gateway # an existing istio Gateway
|
||||||
|
http:
|
||||||
|
- route:
|
||||||
|
- destination:
|
||||||
|
host: terdut-operator-demo.terdut-operator-demo.svc.cluster.local
|
||||||
|
port:
|
||||||
|
number: 8080 # the Service's port -- same name as the
|
||||||
|
# TerdutServer CR, default servicePort
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
policyv1 "k8s.io/api/policy/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -97,6 +98,7 @@ type TerdutServerReconciler struct {
|
|||||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
|
||||||
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups="",resources=services,verbs=get;list;watch;create;update;patch;delete
|
||||||
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
|
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
|
||||||
|
// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete
|
||||||
// +kubebuilder:rbac:groups=acid.zalan.do,resources=postgresqls,verbs=get;list;watch
|
// +kubebuilder:rbac:groups=acid.zalan.do,resources=postgresqls,verbs=get;list;watch
|
||||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
||||||
|
|
||||||
@@ -137,6 +139,9 @@ func (r *TerdutServerReconciler) Reconcile(ctx context.Context, req ctrl.Request
|
|||||||
if err := r.reconcileService(ctx, &srv); err != nil {
|
if err := r.reconcileService(ctx, &srv); err != nil {
|
||||||
return ctrl.Result{}, err
|
return ctrl.Result{}, err
|
||||||
}
|
}
|
||||||
|
if err := r.reconcilePodDisruptionBudget(ctx, &srv); err != nil {
|
||||||
|
return ctrl.Result{}, err
|
||||||
|
}
|
||||||
|
|
||||||
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
meta.SetStatusCondition(&srv.Status.Conditions, metav1.Condition{
|
||||||
Type: terdutv1alpha1.ConditionDatabaseReady,
|
Type: terdutv1alpha1.ConditionDatabaseReady,
|
||||||
@@ -246,6 +251,7 @@ func (r *TerdutServerReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
|||||||
For(&terdutv1alpha1.TerdutServer{}).
|
For(&terdutv1alpha1.TerdutServer{}).
|
||||||
Owns(&appsv1.Deployment{}).
|
Owns(&appsv1.Deployment{}).
|
||||||
Owns(&corev1.Service{}).
|
Owns(&corev1.Service{}).
|
||||||
|
Owns(&policyv1.PodDisruptionBudget{}).
|
||||||
Named("terdutserver").
|
Named("terdutserver").
|
||||||
Complete(r)
|
Complete(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import (
|
|||||||
. "github.com/onsi/gomega"
|
. "github.com/onsi/gomega"
|
||||||
appsv1 "k8s.io/api/apps/v1"
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
policyv1 "k8s.io/api/policy/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
"k8s.io/apimachinery/pkg/api/meta"
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/apimachinery/pkg/util/intstr"
|
||||||
ctrl "sigs.k8s.io/controller-runtime"
|
ctrl "sigs.k8s.io/controller-runtime"
|
||||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||||
|
|
||||||
@@ -705,6 +709,95 @@ var _ = Describe("TerdutServer Controller", func() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
Describe("spec.pod", func() {
|
||||||
|
It("wires pod-level customization onto the right spot on the Deployment", func(ctx SpecContext) {
|
||||||
|
fake, fakeSrv := newFakeTerdutServer()
|
||||||
|
_ = fake
|
||||||
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
||||||
|
|
||||||
|
spec := dsnSpec()
|
||||||
|
qty := resource.MustParse("250m")
|
||||||
|
spec.Pod = terdutv1alpha1.PodSpec{
|
||||||
|
Resources: corev1.ResourceRequirements{Requests: corev1.ResourceList{corev1.ResourceCPU: qty}},
|
||||||
|
Tolerations: []corev1.Toleration{{Key: "dedicated", Operator: corev1.TolerationOpEqual, Value: "terdut", Effect: corev1.TaintEffectNoSchedule}},
|
||||||
|
ExtraEnv: []corev1.EnvVar{{Name: "EXTRA_FLAG", Value: "on"}},
|
||||||
|
ServiceAccountName: "terdut-server-custom",
|
||||||
|
ExtraVolumes: []corev1.Volume{{Name: "extra-ca", VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}}},
|
||||||
|
ExtraVolumeMounts: []corev1.VolumeMount{{Name: "extra-ca", MountPath: "/etc/extra-ca"}},
|
||||||
|
}
|
||||||
|
createServer(ctx, spec)
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx) // Deployment/Service/PDB
|
||||||
|
|
||||||
|
var deploy appsv1.Deployment
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||||
|
|
||||||
|
podSpec := deploy.Spec.Template.Spec
|
||||||
|
Expect(podSpec.Tolerations).To(ConsistOf(spec.Pod.Tolerations))
|
||||||
|
Expect(podSpec.ServiceAccountName).To(Equal("terdut-server-custom"))
|
||||||
|
Expect(podSpec.Volumes).To(ConsistOf(spec.Pod.ExtraVolumes))
|
||||||
|
|
||||||
|
main := podSpec.Containers[0]
|
||||||
|
Expect(main.Name).To(Equal("terdut-server"))
|
||||||
|
Expect(main.Resources).To(Equal(spec.Pod.Resources))
|
||||||
|
Expect(main.VolumeMounts).To(ConsistOf(spec.Pod.ExtraVolumeMounts))
|
||||||
|
Expect(main.Env).To(ContainElement(corev1.EnvVar{Name: "EXTRA_FLAG", Value: "on"}))
|
||||||
|
|
||||||
|
initContainer := podSpec.InitContainers[0]
|
||||||
|
Expect(initContainer.Name).To(Equal("wait-for-postgres"))
|
||||||
|
Expect(initContainer.VolumeMounts).To(BeEmpty(), "extraVolumeMounts must not leak onto wait-for-postgres")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
Describe("spec.pod.disruptionBudget", func() {
|
||||||
|
It("creates an owned PodDisruptionBudget when set, and deletes it once cleared", func(ctx SpecContext) {
|
||||||
|
fake, fakeSrv := newFakeTerdutServer()
|
||||||
|
_ = fake
|
||||||
|
DeferCleanup(fakeSrv.Close)
|
||||||
|
reconciler.NewClient = func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) }
|
||||||
|
|
||||||
|
spec := dsnSpec()
|
||||||
|
minAvail := intstr.FromInt32(1)
|
||||||
|
spec.Pod.DisruptionBudget = &terdutv1alpha1.PodDisruptionBudgetSpec{MinAvailable: &minAvail}
|
||||||
|
createServer(ctx, spec)
|
||||||
|
reconcileOnce(ctx) // finalizer
|
||||||
|
reconcileOnce(ctx) // Deployment/Service/PDB
|
||||||
|
|
||||||
|
var pdb policyv1.PodDisruptionBudget
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, &pdb)).To(Succeed())
|
||||||
|
Expect(pdb.Spec.Selector.MatchLabels).To(Equal(labelsFor(&terdutv1alpha1.TerdutServer{ObjectMeta: metav1.ObjectMeta{Name: name}})))
|
||||||
|
Expect(pdb.Spec.MinAvailable).To(Equal(&minAvail))
|
||||||
|
Expect(pdb.OwnerReferences).To(ContainElement(HaveField("Name", name)))
|
||||||
|
|
||||||
|
srv := &terdutv1alpha1.TerdutServer{}
|
||||||
|
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
||||||
|
srv.Spec.Pod.DisruptionBudget = nil
|
||||||
|
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||||
|
reconcileOnce(ctx)
|
||||||
|
|
||||||
|
err := k8sClient.Get(ctx, objKey, &policyv1.PodDisruptionBudget{})
|
||||||
|
Expect(apierrors.IsNotFound(err)).To(BeTrue(), "PodDisruptionBudget should be deleted once spec.pod.disruptionBudget is cleared")
|
||||||
|
})
|
||||||
|
|
||||||
|
It("rejects both minAvailable and maxUnavailable set together, and neither set", func(ctx SpecContext) {
|
||||||
|
bothSet := dsnSpec()
|
||||||
|
minAvail, maxUnavail := intstr.FromInt32(1), intstr.FromInt32(1)
|
||||||
|
bothSet.Pod.DisruptionBudget = &terdutv1alpha1.PodDisruptionBudgetSpec{MinAvailable: &minAvail, MaxUnavailable: &maxUnavail}
|
||||||
|
Expect(k8sClient.Create(ctx, &terdutv1alpha1.TerdutServer{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: name + "-both", Namespace: operatorNamespace},
|
||||||
|
Spec: bothSet,
|
||||||
|
})).To(HaveOccurred())
|
||||||
|
|
||||||
|
neitherSet := dsnSpec()
|
||||||
|
neitherSet.Pod.DisruptionBudget = &terdutv1alpha1.PodDisruptionBudgetSpec{}
|
||||||
|
Expect(k8sClient.Create(ctx, &terdutv1alpha1.TerdutServer{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: name + "-neither", Namespace: operatorNamespace},
|
||||||
|
Spec: neitherSet,
|
||||||
|
})).To(HaveOccurred())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
Describe("deletion", func() {
|
Describe("deletion", func() {
|
||||||
It("removes the credentials and checkpoint Secrets and the finalizer", func(ctx SpecContext) {
|
It("removes the credentials and checkpoint Secrets and the finalizer", func(ctx SpecContext) {
|
||||||
fake, fakeSrv := newFakeTerdutServer()
|
fake, fakeSrv := newFakeTerdutServer()
|
||||||
|
|||||||
@@ -48,11 +48,26 @@ func (r *TerdutServerReconciler) reconcileDeployment(
|
|||||||
// overlapping during a rollout would both page for the same
|
// overlapping during a rollout would both page for the same
|
||||||
// incident (matches the chart's own deployment.yaml comment).
|
// incident (matches the chart's own deployment.yaml comment).
|
||||||
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
|
deploy.Spec.Strategy = appsv1.DeploymentStrategy{Type: appsv1.RecreateDeploymentStrategyType}
|
||||||
|
pod := srv.Spec.Pod
|
||||||
deploy.Spec.Template = corev1.PodTemplateSpec{
|
deploy.Spec.Template = corev1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
// pod.Annotations is assigned directly, not merged -- nothing
|
||||||
|
// else sets pod-template annotations today. If a future change
|
||||||
|
// needs the controller to set one of its own (e.g. a
|
||||||
|
// Prometheus-scrape annotation), this needs to become a real
|
||||||
|
// map merge with a stated precedence rather than silently
|
||||||
|
// clobbering one side.
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Labels: labels, Annotations: pod.Annotations},
|
||||||
Spec: corev1.PodSpec{
|
Spec: corev1.PodSpec{
|
||||||
EnableServiceLinks: new(false),
|
EnableServiceLinks: new(false),
|
||||||
|
NodeSelector: pod.NodeSelector,
|
||||||
|
Tolerations: pod.Tolerations,
|
||||||
|
Affinity: pod.Affinity,
|
||||||
|
TopologySpreadConstraints: pod.TopologySpreadConstraints,
|
||||||
|
SecurityContext: pod.SecurityContext,
|
||||||
|
ServiceAccountName: pod.ServiceAccountName,
|
||||||
|
ImagePullSecrets: pod.ImagePullSecrets,
|
||||||
InitContainers: []corev1.Container{waitForPostgresContainer(dbEnv)},
|
InitContainers: []corev1.Container{waitForPostgresContainer(dbEnv)},
|
||||||
|
Volumes: pod.ExtraVolumes,
|
||||||
Containers: []corev1.Container{{
|
Containers: []corev1.Container{{
|
||||||
Name: "terdut-server",
|
Name: "terdut-server",
|
||||||
Image: fmt.Sprintf("%s:%s", srv.Spec.Image.Repository, srv.Spec.Image.Tag),
|
Image: fmt.Sprintf("%s:%s", srv.Spec.Image.Repository, srv.Spec.Image.Tag),
|
||||||
@@ -62,6 +77,10 @@ func (r *TerdutServerReconciler) reconcileDeployment(
|
|||||||
Protocol: corev1.ProtocolTCP,
|
Protocol: corev1.ProtocolTCP,
|
||||||
}},
|
}},
|
||||||
Env: buildEnv(srv, dbEnv),
|
Env: buildEnv(srv, dbEnv),
|
||||||
|
EnvFrom: pod.ExtraEnvFrom,
|
||||||
|
VolumeMounts: pod.ExtraVolumeMounts,
|
||||||
|
Resources: pod.Resources,
|
||||||
|
SecurityContext: pod.ContainerSecurityContext,
|
||||||
LivenessProbe: healthzProbe(),
|
LivenessProbe: healthzProbe(),
|
||||||
ReadinessProbe: healthzProbe(),
|
ReadinessProbe: healthzProbe(),
|
||||||
}},
|
}},
|
||||||
@@ -151,7 +170,10 @@ func healthzProbe() *corev1.Probe {
|
|||||||
// field-for-field (confirmed against that source, not reconstructed from
|
// field-for-field (confirmed against that source, not reconstructed from
|
||||||
// DESIGN.md's illustrative YAML alone) — dbEnv (TERDUT_DB_DSN, optionally
|
// DESIGN.md's illustrative YAML alone) — dbEnv (TERDUT_DB_DSN, optionally
|
||||||
// PGPASSWORD) comes from resolveDatabaseEnv, since which of §8's two paths
|
// PGPASSWORD) comes from resolveDatabaseEnv, since which of §8's two paths
|
||||||
// produced it doesn't matter past this point.
|
// produced it doesn't matter past this point. spec.pod.extraEnv is appended
|
||||||
|
// last, after every fixed var -- this is the one place that owns "what env
|
||||||
|
// this container gets," so the escape hatch lives here rather than being
|
||||||
|
// appended separately in reconcileDeployment.
|
||||||
func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.EnvVar {
|
func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.EnvVar {
|
||||||
env := []corev1.EnvVar{{Name: "TERDUT_ADDR", Value: fmt.Sprintf(":%d", servicePort(srv))}}
|
env := []corev1.EnvVar{{Name: "TERDUT_ADDR", Value: fmt.Sprintf(":%d", servicePort(srv))}}
|
||||||
env = append(env, dbEnv...)
|
env = append(env, dbEnv...)
|
||||||
@@ -230,7 +252,7 @@ func buildEnv(srv *terdutv1alpha1.TerdutServer, dbEnv []corev1.EnvVar) []corev1.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return env
|
return append(env, srv.Spec.Pod.ExtraEnv...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func secretEnvSource(ref *terdutv1alpha1.SecretKeyRef) *corev1.EnvVarSource {
|
func secretEnvSource(ref *terdutv1alpha1.SecretKeyRef) *corev1.EnvVarSource {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
policyv1 "k8s.io/api/policy/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||||
|
|
||||||
|
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// reconcilePodDisruptionBudget creates/updates the PodDisruptionBudget
|
||||||
|
// spec.pod.disruptionBudget asks for, or deletes a previously-created one
|
||||||
|
// when the field has been cleared -- the one conditionally-created child
|
||||||
|
// object in this controller (Deployment/Service are unconditional). Owned
|
||||||
|
// by srv, same plain-OwnerReference shape as Deployment/Service (DESIGN.md
|
||||||
|
// §7): same namespace, GC handles it, no finalizer needed.
|
||||||
|
func (r *TerdutServerReconciler) reconcilePodDisruptionBudget(ctx context.Context, srv *terdutv1alpha1.TerdutServer) error {
|
||||||
|
pdb := &policyv1.PodDisruptionBudget{ObjectMeta: metav1.ObjectMeta{Name: srv.Name, Namespace: srv.Namespace}}
|
||||||
|
|
||||||
|
spec := srv.Spec.Pod.DisruptionBudget
|
||||||
|
if spec == nil {
|
||||||
|
if err := r.Delete(ctx, pdb); err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := controllerutil.CreateOrUpdate(ctx, r.Client, pdb, func() error {
|
||||||
|
pdb.Spec.Selector = &metav1.LabelSelector{MatchLabels: labelsFor(srv)}
|
||||||
|
pdb.Spec.MinAvailable = spec.MinAvailable
|
||||||
|
pdb.Spec.MaxUnavailable = spec.MaxUnavailable
|
||||||
|
return controllerutil.SetControllerReference(srv, pdb, r.Scheme)
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user