Stage 2: TerdutTeam (create, mint team credential, rename/oidc-groups, delete)
CI / test (push) Successful in 1m41s
CI / test (push) Successful in 1m41s
Implements ROADMAP.md Stage 2 against terdut-server's now-real
GET /api/teams?name= (TEAM-LOOKUP.md, landed in terdut-server just
before this commit) -- without it, the adopt-on-409 pattern this
controller depends on for team creation had no server-side lookup to
call, the same gap TerdutServer's own bootstrap flow hit and fixed in
Stage 1.
- api/v1alpha1: TerdutTeamSpec per DESIGN.md §4.2 (serverRef, displayName,
oidc). status.credentialsSecretRef drops namespace for key, matching
the fix already applied to TerdutServer's.
- internal/controller:
- terdutteam_controller.go: resolves serverRef (same-namespace by
default; cross-namespace gated by the target TerdutServer's
spec.allowedTeams, DESIGN.md §4.6), waits for that TerdutServer to be
Bootstrapped (no cross-controller RPC -- reads its
status.credentialsSecretRef directly, DESIGN.md §5), creates the team
and mints its team-scoped credential using the TerdutServer's
instance-scoped one, then applies rename/oidc-groups with the
team-scoped credential every reconcile (both are idempotent PUTs of
the whole resource -- applied unconditionally rather than diffed
against a stored last-applied value, same "cheap because it's small"
reasoning §5 already gives the escalation policy's whole-policy PUT).
- terdutteam_allowedteams.go: the §4.6 consent check in isolation from
any client, unit-tested directly against hand-built inputs.
- terdutteam_bootstrap.go: create-or-adopt-on-409 for the team itself
(via TEAM-LOOKUP.md) and for its team-scoped service account (via the
same GET-by-name+mint-new-key pattern Stage 1 already uses for the
instance account).
- secrets.go: extracted TerdutServer's write/read-credential-Secret
helpers into free functions, now shared by both controllers rather
than duplicated.
- Finalizer deletes the team server-side (owner-gated, needs the
team-scoped credential -- confirmed against source that an
instance-scoped one does not satisfy requireTeamOwner, same finding
as TEAM-LOOKUP.md's) and cleans up its credentials Secret. A team
created but never fully reconciled to Ready (no team-scoped
credential ever minted) is left orphaned server-side on delete -- a
known, documented limitation (terdut-server has no delete path that
doesn't require owner-equivalent access), not a silent gap.
- internal/tdclient: Team type, CreateTeam, GetTeamByName, RenameTeam,
DeleteTeam, SetTeamOIDCGroups, CreateTeamServiceAccount -- matching
terdut-server's real handlers' shapes field-for-field, same as Stage
1's client additions.
- Tests: envtest covering the happy path, both not-ready reasons
(ServerRefNotFound, WaitingForServer), cross-namespace allow/deny
(default-closed and explicit All), both adopt-on-409 paths (team
itself, team-scoped service account), and deletion. Extended the shared
fakeTerdutServer (Stage 1) with team endpoints rather than writing a
second, separately-drifting fake. 72.8%/30.6% coverage, 0 lint issues.
Verified locally: make fmt lint test build all clean.
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
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"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
// credentialsSecretDataKey is the fixed data key every generated
|
||||
// credentials Secret this controller writes uses — "whatever a human chose"
|
||||
// only applied to the bring-your-own input an earlier design draft had and
|
||||
// removed (DESIGN.md §6); every Secret any controller in this repo
|
||||
// generates itself uses this one key.
|
||||
const credentialsSecretDataKey = "token"
|
||||
|
||||
// writeOperatorSecret creates or replaces a Secret in namespace (always the
|
||||
// operator's own, DESIGN.md §6) holding one raw value under
|
||||
// credentialsSecretDataKey. Shared by every controller that generates a
|
||||
// credential there -- TerdutServer's instance-scoped key and the bootstrap
|
||||
// admin-key checkpoint, TerdutTeam's team-scoped key.
|
||||
func writeOperatorSecret(ctx context.Context, c client.Client, namespace, name, rawValue string) error {
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
||||
Data: map[string][]byte{credentialsSecretDataKey: []byte(rawValue)},
|
||||
}
|
||||
if err := c.Create(ctx, secret); err != nil {
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
return c.Update(ctx, secret)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readOperatorSecret reads one generated credential back, by the
|
||||
// SecretKeyRef a controller's own status stores (always resolved in the
|
||||
// operator's own namespace, never the referencing CR's -- DESIGN.md §6).
|
||||
func readOperatorSecret(ctx context.Context, c client.Client, namespace string, ref *terdutv1alpha1.SecretKeyRef) (string, error) {
|
||||
var secret corev1.Secret
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &secret); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(secret.Data[ref.Key]), nil
|
||||
}
|
||||
@@ -15,12 +15,6 @@ import (
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
// credentialsSecretDataKey is the fixed data key every generated
|
||||
// credentials Secret this controller writes uses — "whatever a human chose"
|
||||
// only applied to the bring-your-own input this design removed (DESIGN.md
|
||||
// §6); every Secret this controller itself generates uses this one key.
|
||||
const credentialsSecretDataKey = "token"
|
||||
|
||||
// bootstrapStateLostError is DESIGN.md §6's one genuinely pathological
|
||||
// case: a checkpointed admin credential was used and then lost before the
|
||||
// lasting credential it was for could be persisted. Distinct from a plain
|
||||
@@ -54,7 +48,7 @@ func (r *TerdutServerReconciler) reconcileBootstrap(ctx context.Context, srv *te
|
||||
}
|
||||
|
||||
credsName := credentialsSecretName(srv)
|
||||
if err := r.writeSecret(ctx, credsName, instanceKey); err != nil {
|
||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, instanceKey); err != nil {
|
||||
return err
|
||||
}
|
||||
srv.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
|
||||
@@ -100,7 +94,7 @@ func (r *TerdutServerReconciler) getOrCreateCheckpointedAdminKey(ctx context.Con
|
||||
return "", fmt.Errorf("POST /api/bootstrap: %w", err)
|
||||
}
|
||||
|
||||
if err := r.writeSecret(ctx, checkpointName, result.APIKey.Key); err != nil {
|
||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, checkpointName, result.APIKey.Key); err != nil {
|
||||
return "", fmt.Errorf("checkpointing admin key: %w", err)
|
||||
}
|
||||
return result.APIKey.Key, nil
|
||||
@@ -135,19 +129,3 @@ func (r *TerdutServerReconciler) getOrMintInstanceServiceAccountKey(ctx context.
|
||||
}
|
||||
return key.Key, nil
|
||||
}
|
||||
|
||||
// writeSecret creates or replaces a Secret in the operator's own namespace
|
||||
// holding one raw value under credentialsSecretDataKey.
|
||||
func (r *TerdutServerReconciler) writeSecret(ctx context.Context, name, rawValue string) error {
|
||||
secret := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: r.OperatorNamespace},
|
||||
Data: map[string][]byte{credentialsSecretDataKey: []byte(rawValue)},
|
||||
}
|
||||
if err := r.Create(ctx, secret); err != nil {
|
||||
if apierrors.IsAlreadyExists(err) {
|
||||
return r.Update(ctx, secret)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ import (
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
// fakeVersionString/errJSONKey are shared by every response fakeTerdutServer
|
||||
// writes -- goconst would otherwise flag "test" and "error" as repeated
|
||||
// literals across its handlers.
|
||||
const (
|
||||
fakeVersionString = "test"
|
||||
errJSONKey = "error"
|
||||
)
|
||||
|
||||
// fakeTerdutServer reproduces the exact stateful semantics of
|
||||
// /api/bootstrap, /api/service-accounts and /api/version that the
|
||||
// bootstrap flow depends on (DESIGN.md §6, §11: "terdut-server's REST API
|
||||
@@ -37,10 +45,23 @@ type fakeTerdutServer struct {
|
||||
nextID int64
|
||||
accounts map[string]int64 // name -> id
|
||||
keyMints map[int64]int // id -> number of keys minted so far
|
||||
|
||||
nextTeamID int64
|
||||
teams map[string]int64 // name -> id
|
||||
teamNames map[int64]string // id -> current name (renames update this)
|
||||
teamOIDC map[int64][2]string
|
||||
teamDelete map[int64]bool // id -> true once DELETEd, for 404-on-redelete
|
||||
}
|
||||
|
||||
func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) {
|
||||
f := &fakeTerdutServer{accounts: map[string]int64{}, keyMints: map[int64]int{}}
|
||||
f := &fakeTerdutServer{
|
||||
accounts: map[string]int64{},
|
||||
keyMints: map[int64]int{},
|
||||
teams: map[string]int64{},
|
||||
teamNames: map[int64]string{},
|
||||
teamOIDC: map[int64][2]string{},
|
||||
teamDelete: map[int64]bool{},
|
||||
}
|
||||
return f, httptest.NewServer(f)
|
||||
}
|
||||
|
||||
@@ -50,11 +71,11 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
switch {
|
||||
case r.URL.Path == "/api/version":
|
||||
writeJSON(w, http.StatusOK, map[string]string{"version": "test"})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"version": fakeVersionString})
|
||||
|
||||
case r.URL.Path == "/api/bootstrap" && r.Method == http.MethodPost:
|
||||
if f.bootstrapped || f.bootstrap403 {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": "bootstrap already completed"})
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{errJSONKey: "bootstrap already completed"})
|
||||
return
|
||||
}
|
||||
f.bootstrapped = true
|
||||
@@ -69,7 +90,7 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if _, exists := f.accounts[req.Name]; exists {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "a service account with that name already exists"})
|
||||
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a service account with that name already exists"})
|
||||
return
|
||||
}
|
||||
f.nextID++
|
||||
@@ -90,6 +111,32 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []tdclient.ServiceAccount{{ID: id, Name: name, Scope: "instance"}})
|
||||
|
||||
case r.URL.Path == "/api/teams" && r.Method == http.MethodPost:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if _, exists := f.teams[req.Name]; exists {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{errJSONKey: "a team with that name already exists"})
|
||||
return
|
||||
}
|
||||
f.nextTeamID++
|
||||
id := f.nextTeamID
|
||||
f.teams[req.Name] = id
|
||||
f.teamNames[id] = req.Name
|
||||
writeJSON(w, http.StatusCreated, tdclient.Team{ID: id, Name: req.Name})
|
||||
|
||||
case r.URL.Path == "/api/teams" && r.Method == http.MethodGet:
|
||||
// The controller never calls this without ?name= (TEAM-LOOKUP.md's
|
||||
// own lookup shape) -- the fake only needs to answer that form.
|
||||
name := r.URL.Query().Get("name")
|
||||
id, exists := f.teams[name]
|
||||
if !exists {
|
||||
writeJSON(w, http.StatusOK, []tdclient.Team{})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, []tdclient.Team{{ID: id, Name: f.teamNames[id]}})
|
||||
|
||||
default:
|
||||
if id, name, ok := parseKeysPath(r.URL.Path); ok && r.Method == http.MethodPost {
|
||||
f.keyMints[id]++
|
||||
@@ -99,10 +146,78 @@ func (f *fakeTerdutServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
if id, ok := parseTeamPath(r.URL.Path); ok {
|
||||
f.handleTeamByID(w, r, id)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// handleTeamByID answers PUT /api/teams/{id}, PUT /api/teams/{id}/oidc-groups
|
||||
// and DELETE /api/teams/{id}.
|
||||
func (f *fakeTerdutServer) handleTeamByID(w http.ResponseWriter, r *http.Request, id int64) {
|
||||
oidcSuffix := fmt.Sprintf("/api/teams/%d/oidc-groups", id)
|
||||
|
||||
switch {
|
||||
case r.URL.Path == oidcSuffix && r.Method == http.MethodPut:
|
||||
var req struct {
|
||||
MemberGroup string `json:"member_group"`
|
||||
OwnerGroup string `json:"owner_group"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if _, exists := f.teamNames[id]; !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
f.teamOIDC[id] = [2]string{req.MemberGroup, req.OwnerGroup}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodPut:
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
oldName, exists := f.teamNames[id]
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.teams, oldName)
|
||||
f.teamNames[id] = req.Name
|
||||
f.teams[req.Name] = id
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
case r.URL.Path == fmt.Sprintf("/api/teams/%d", id) && r.Method == http.MethodDelete:
|
||||
name, exists := f.teamNames[id]
|
||||
if !exists {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
delete(f.teams, name)
|
||||
delete(f.teamNames, id)
|
||||
f.teamDelete[id] = true
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// parseTeamPath extracts the numeric id from "/api/teams/{id}" or
|
||||
// "/api/teams/{id}/oidc-groups" -- anything with more or fewer segments
|
||||
// doesn't match (handleTeamByID's own switch sorts out which of the two).
|
||||
func parseTeamPath(path string) (id int64, ok bool) {
|
||||
var parsedID int64
|
||||
if n, err := fmt.Sscanf(path, "/api/teams/%d/oidc-groups", &parsedID); err == nil && n == 1 {
|
||||
return parsedID, true
|
||||
}
|
||||
if n, err := fmt.Sscanf(path, "/api/teams/%d", &parsedID); err == nil && n == 1 {
|
||||
return parsedID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseKeysPath(path string) (id int64, mintName string, ok bool) {
|
||||
var parsedID int64
|
||||
n, err := fmt.Sscanf(path, "/api/service-accounts/%d/keys", &parsedID)
|
||||
@@ -151,9 +266,9 @@ var _ = Describe("TerdutServer Controller", func() {
|
||||
|
||||
dsnSpec := func() terdutv1alpha1.TerdutServerSpec {
|
||||
return terdutv1alpha1.TerdutServerSpec{
|
||||
Image: terdutv1alpha1.ImageSpec{Repository: "example.invalid/terdut-server", Tag: "test"},
|
||||
Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
|
||||
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "terdut.example.invalid", ServicePort: 8080},
|
||||
Database: terdutv1alpha1.DatabaseSpec{DSN: "postgres://terdut@test-postgres:5432/terdut?sslmode=require"},
|
||||
Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,7 +327,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
||||
for _, e := range deploy.Spec.Template.Spec.Containers[0].Env {
|
||||
envNames[e.Name] = e.Value
|
||||
}
|
||||
Expect(envNames).To(HaveKeyWithValue("TERDUT_DB_DSN", "postgres://terdut@test-postgres:5432/terdut?sslmode=require"))
|
||||
Expect(envNames).To(HaveKeyWithValue("TERDUT_DB_DSN", testDSN))
|
||||
Expect(envNames).To(HaveKeyWithValue("TERDUT_OPERATOR_MODE", "true"))
|
||||
|
||||
var svc corev1.Service
|
||||
@@ -266,7 +381,7 @@ var _ = Describe("TerdutServer Controller", func() {
|
||||
|
||||
// The earlier attempt's checkpoint survived (that's how this
|
||||
// reconcile can authenticate at all to recover).
|
||||
Expect(reconciler.writeSecret(ctx, checkpointSecretName(&terdutv1alpha1.TerdutServer{
|
||||
Expect(writeOperatorSecret(ctx, k8sClient, operatorNamespace, checkpointSecretName(&terdutv1alpha1.TerdutServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: operatorNamespace},
|
||||
}), "admin-key-raw")).To(Succeed())
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/labels"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
// allowedTeamsPermits implements DESIGN.md §4.6's consent check in
|
||||
// isolation from any client, so it's unit-testable against hand-built
|
||||
// inputs the way classifyClusterGetError is. Same-namespace callers never
|
||||
// call this at all (DESIGN.md §4.6: "same-namespace TerdutTeams are always
|
||||
// allowed, regardless of this field") — it's only ever consulted for a
|
||||
// cross-namespace serverRef.
|
||||
func allowedTeamsPermits(allowed terdutv1alpha1.AllowedTeamsNamespaces, nsLabels map[string]string) (bool, error) {
|
||||
switch allowed.From {
|
||||
case "All":
|
||||
return true, nil
|
||||
case "Selector":
|
||||
if allowed.Selector == nil {
|
||||
return false, nil
|
||||
}
|
||||
sel, err := metav1.LabelSelectorAsSelector(allowed.Selector)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return sel.Matches(labels.Set(nsLabels)), nil
|
||||
default:
|
||||
// "", "None", "Same" -- Same is equivalent to None in effect for a
|
||||
// cross-namespace caller (DESIGN.md §4.6: same-namespace is
|
||||
// unrestricted either way, Same only exists for parity with the
|
||||
// upstream enum this mirrors).
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
const (
|
||||
testSelectorLabelKey = "terdut.ryuvia.com/allowed"
|
||||
testSelectorFrom = "Selector"
|
||||
testSelectorLabelValue = "true"
|
||||
)
|
||||
|
||||
func TestAllowedTeamsPermits(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
allowed terdutv1alpha1.AllowedTeamsNamespaces
|
||||
labels map[string]string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "unset defaults closed",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "None is closed",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "None"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Same is closed (same-namespace never reaches this function anyway)",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "Same"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "All is open regardless of labels",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: "All"},
|
||||
labels: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Selector with no selector set is closed",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{From: testSelectorFrom},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Selector matching labels is open",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{
|
||||
From: testSelectorFrom,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{testSelectorLabelKey: testSelectorLabelValue}},
|
||||
},
|
||||
labels: map[string]string{testSelectorLabelKey: testSelectorLabelValue},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Selector not matching labels is closed",
|
||||
allowed: terdutv1alpha1.AllowedTeamsNamespaces{
|
||||
From: testSelectorFrom,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{testSelectorLabelKey: testSelectorLabelValue}},
|
||||
},
|
||||
labels: map[string]string{"something-else": "true"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := allowedTeamsPermits(tc.allowed, tc.labels)
|
||||
if err != nil {
|
||||
t.Fatalf("allowedTeamsPermits() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("allowedTeamsPermits() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
// createOrAdoptTeam calls POST /api/teams with the TerdutServer's
|
||||
// instance-scoped credential, or, if an earlier interrupted attempt
|
||||
// already created this name (409), adopts it via GET /api/teams?name=
|
||||
// (TEAM-LOOKUP.md) rather than treating the conflict as an error --
|
||||
// DESIGN.md §5's general adopt-on-conflict rule, the same shape
|
||||
// TerdutServer's own bootstrap flow uses for minting its instance account.
|
||||
func (r *TerdutTeamReconciler) createOrAdoptTeam(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
|
||||
created, err := instanceClient.CreateTeam(ctx, team.Spec.DisplayName)
|
||||
if err == nil {
|
||||
team.Status.TeamID = created.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
|
||||
if !ok || statusErr.Code != http.StatusConflict {
|
||||
return fmt.Errorf("POST /api/teams: %w", err)
|
||||
}
|
||||
|
||||
found, err := instanceClient.GetTeamByName(ctx, team.Spec.DisplayName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET /api/teams?name=%s (adopting after 409): %w", team.Spec.DisplayName, err)
|
||||
}
|
||||
if found == nil {
|
||||
// Genuinely pathological, not just a narrow crash window: the name
|
||||
// was taken a moment ago and isn't now. Surfaced as a plain error
|
||||
// (standard requeue-with-backoff) rather than a dedicated
|
||||
// condition -- there's no documented recovery to point at that
|
||||
// differs from "try again".
|
||||
return fmt.Errorf("POST /api/teams 409'd for %q but GET found nothing", team.Spec.DisplayName)
|
||||
}
|
||||
team.Status.TeamID = found.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// mintTeamCredential mints this team's own team-scoped service account,
|
||||
// using the TerdutServer's instance-scoped credential (DESIGN.md §6 point
|
||||
// 3: an instance-scoped caller may do this against any team). Adopts via
|
||||
// GET+mint-new-key on a 409, the same pattern TerdutServer's own bootstrap
|
||||
// flow uses.
|
||||
func (r *TerdutTeamReconciler) mintTeamCredential(ctx context.Context, team *terdutv1alpha1.TerdutTeam, instanceClient *tdclient.Client) error {
|
||||
saName := teamServiceAccountName(team)
|
||||
|
||||
result, err := instanceClient.CreateTeamServiceAccount(ctx, saName, team.Status.TeamID)
|
||||
var key string
|
||||
if err == nil {
|
||||
key = result.Key.Key
|
||||
} else {
|
||||
statusErr, ok := errors.AsType[*tdclient.StatusError](err)
|
||||
if !ok || statusErr.Code != http.StatusConflict {
|
||||
return fmt.Errorf("POST /api/service-accounts (team scope): %w", err)
|
||||
}
|
||||
sa, err := instanceClient.GetServiceAccountByName(ctx, saName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("GET /api/service-accounts?name=%s (adopting after 409): %w", saName, err)
|
||||
}
|
||||
if sa == nil {
|
||||
return fmt.Errorf("POST /api/service-accounts 409'd for %q but GET found nothing", saName)
|
||||
}
|
||||
minted, err := instanceClient.CreateServiceAccountKey(ctx, sa.ID, "initial")
|
||||
if err != nil {
|
||||
return fmt.Errorf("POST /api/service-accounts/%d/keys (adopting after 409): %w", sa.ID, err)
|
||||
}
|
||||
key = minted.Key
|
||||
}
|
||||
|
||||
credsName := teamCredentialsSecretName(team)
|
||||
if err := writeOperatorSecret(ctx, r.Client, r.OperatorNamespace, credsName, key); err != nil {
|
||||
return err
|
||||
}
|
||||
team.Status.CredentialsSecretRef = &terdutv1alpha1.SecretKeyRef{Name: credsName, Key: credentialsSecretDataKey}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
|
||||
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||
"sigs.k8s.io/controller-runtime/pkg/recorder"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
// teamFinalizerName cleans up the team-scoped credentials Secret this
|
||||
// controller generates in the operator's own namespace on delete.
|
||||
const teamFinalizerName = "terdut.ryuvia.com/terdutteam"
|
||||
|
||||
// TerdutTeamReconciler reconciles a TerdutTeam object.
|
||||
//
|
||||
// Every child resolves its own teamRef/serverRef independently and never
|
||||
// chains up through another controller (DESIGN.md §5) — this one talks
|
||||
// directly to the TerdutServer it references and to terdut-server's API,
|
||||
// never to TerdutServerReconciler.
|
||||
type TerdutTeamReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
|
||||
// OperatorNamespace is where every credentials Secret this controller
|
||||
// reads (the referenced TerdutServer's) or writes (this team's own)
|
||||
// lives (DESIGN.md §6) — never a TerdutTeam's or TerdutServer's own
|
||||
// namespace.
|
||||
OperatorNamespace string
|
||||
|
||||
Recorder recorder.EventRecorder
|
||||
NewClient func(endpoint string) *tdclient.Client
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/finalizers,verbs=update
|
||||
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
|
||||
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
|
||||
|
||||
func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
log := logf.FromContext(ctx)
|
||||
|
||||
var team terdutv1alpha1.TerdutTeam
|
||||
if err := r.Get(ctx, req.NamespacedName, &team); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
if !team.DeletionTimestamp.IsZero() {
|
||||
return r.reconcileTeamDelete(ctx, &team)
|
||||
}
|
||||
|
||||
if !controllerutil.ContainsFinalizer(&team, teamFinalizerName) {
|
||||
controllerutil.AddFinalizer(&team, teamFinalizerName)
|
||||
if err := r.Update(ctx, &team); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
srv, resolveErr := r.resolveServerRef(ctx, &team)
|
||||
if resolveErr != nil {
|
||||
return r.setTeamNotReady(ctx, &team, resolveErr.reason, resolveErr.message, waitInterval)
|
||||
}
|
||||
if srv.Status.CredentialsSecretRef == nil {
|
||||
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer,
|
||||
fmt.Sprintf("TerdutServer %q is not Bootstrapped yet", srv.Name), waitInterval)
|
||||
}
|
||||
|
||||
newClient := r.NewClient
|
||||
if newClient == nil {
|
||||
newClient = tdclient.New
|
||||
}
|
||||
|
||||
instanceKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, srv.Status.CredentialsSecretRef)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
instanceClient := newClient(serviceURL(srv)).WithToken(instanceKey)
|
||||
|
||||
if team.Status.TeamID == 0 {
|
||||
if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if team.Status.CredentialsSecretRef == nil {
|
||||
if err := r.mintTeamCredential(ctx, &team, instanceClient); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
|
||||
if err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
teamClient := newClient(serviceURL(srv)).WithToken(teamKey)
|
||||
|
||||
// Owner-gated on terdut-server, so this always runs with the
|
||||
// team-scoped credential just minted above, never the instance-scoped
|
||||
// one used to create the team (internal/api/teams.go's requireTeamOwner
|
||||
// has no branch for an instance-scoped service account, confirmed
|
||||
// against source). Applied unconditionally rather than diffed against a
|
||||
// stored "last-applied" value: both calls are idempotent PUTs of the
|
||||
// whole resource, the same "cheap because it's small" reasoning §5
|
||||
// already applies to the escalation policy's whole-policy PUT.
|
||||
if err := teamClient.RenameTeam(ctx, team.Status.TeamID, team.Spec.DisplayName); err != nil {
|
||||
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", team.Status.TeamID, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||
Type: terdutv1alpha1.ConditionTeamReady,
|
||||
Status: metav1.ConditionTrue,
|
||||
Reason: terdutv1alpha1.ReasonTeamAdopted,
|
||||
Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name),
|
||||
})
|
||||
team.Status.ObservedGeneration = team.Generation
|
||||
if err := r.Status().Update(ctx, &team); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if r.Recorder != nil {
|
||||
r.Recorder.Eventf(&team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonTeamAdopted, terdutv1alpha1.ReasonTeamAdopted,
|
||||
"team ready")
|
||||
}
|
||||
log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID)
|
||||
|
||||
return ctrl.Result{RequeueAfter: resyncInterval}, nil
|
||||
}
|
||||
|
||||
// teamError carries a condition reason/message, the same role databaseError
|
||||
// plays for TerdutServer: an expected, requeue-and-retry outcome, not a
|
||||
// reconcile failure.
|
||||
type teamError struct {
|
||||
reason string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *teamError) Error() string { return e.message }
|
||||
|
||||
// resolveServerRef resolves spec.serverRef, including the cross-namespace
|
||||
// consent check (DESIGN.md §4.6) when serverRef.namespace differs from this
|
||||
// TerdutTeam's own.
|
||||
func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, *teamError) {
|
||||
ns := team.Spec.ServerRef.Namespace
|
||||
if ns == "" {
|
||||
ns = team.Namespace
|
||||
}
|
||||
|
||||
var srv terdutv1alpha1.TerdutServer
|
||||
if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &srv); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil, &teamError{
|
||||
reason: terdutv1alpha1.ReasonServerRefNotFound,
|
||||
message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns),
|
||||
}
|
||||
}
|
||||
return nil, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()}
|
||||
}
|
||||
|
||||
if ns == team.Namespace {
|
||||
return &srv, nil
|
||||
}
|
||||
|
||||
var ownNamespace corev1.Namespace
|
||||
if err := r.Get(ctx, client.ObjectKey{Name: team.Namespace}, &ownNamespace); err != nil {
|
||||
return nil, &teamError{
|
||||
reason: terdutv1alpha1.ReasonRefNotPermitted,
|
||||
message: fmt.Sprintf("could not read this TerdutTeam's own namespace %q labels: %v", team.Namespace, err),
|
||||
}
|
||||
}
|
||||
permitted, err := allowedTeamsPermits(srv.Spec.AllowedTeams.Namespaces, ownNamespace.Labels)
|
||||
if err != nil {
|
||||
return nil, &teamError{reason: terdutv1alpha1.ReasonRefNotPermitted, message: err.Error()}
|
||||
}
|
||||
if !permitted {
|
||||
return nil, &teamError{
|
||||
reason: terdutv1alpha1.ReasonRefNotPermitted,
|
||||
message: fmt.Sprintf("TerdutServer %q/%q's spec.allowedTeams does not admit namespace %q",
|
||||
ns, team.Spec.ServerRef.Name, team.Namespace),
|
||||
}
|
||||
}
|
||||
return &srv, nil
|
||||
}
|
||||
|
||||
func (r *TerdutTeamReconciler) setTeamNotReady(
|
||||
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration,
|
||||
) (ctrl.Result, error) {
|
||||
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
|
||||
Type: terdutv1alpha1.ConditionTeamReady,
|
||||
Status: metav1.ConditionFalse,
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
})
|
||||
team.Status.ObservedGeneration = team.Generation
|
||||
if err := r.Status().Update(ctx, team); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if r.Recorder != nil {
|
||||
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, reason, reason, message)
|
||||
}
|
||||
return ctrl.Result{RequeueAfter: d}, nil
|
||||
}
|
||||
|
||||
// teamCredentialsSecretName/teamServiceAccountName follow the same
|
||||
// <namespace>.<name>-suffix convention TerdutServer's secrets use
|
||||
// (DESIGN.md §6 point 3), keyed on the TerdutTeam CR's own identity rather
|
||||
// than its (mutable) displayName -- a service account's own name is
|
||||
// globally unique across the whole install (internal/db/migrations/
|
||||
// 014_service_accounts.sql's UNIQUE constraint, confirmed against source),
|
||||
// so this has to be collision-safe the same way the credentials Secret
|
||||
// names already are.
|
||||
func teamCredentialsSecretName(team *terdutv1alpha1.TerdutTeam) string {
|
||||
return fmt.Sprintf("%s.%s-team-credentials", team.Namespace, team.Name)
|
||||
}
|
||||
|
||||
func teamServiceAccountName(team *terdutv1alpha1.TerdutTeam) string {
|
||||
return fmt.Sprintf("terdut-team.%s.%s", team.Namespace, team.Name)
|
||||
}
|
||||
|
||||
// reconcileTeamDelete cleans up the team-scoped credentials Secret and, if
|
||||
// one was ever minted, deletes the team server-side first. If
|
||||
// status.teamID or status.credentialsSecretRef was never set (the CR was
|
||||
// deleted before reconciliation ever got that far), there is deliberately
|
||||
// no attempt to clean up server-side: terdut-server's DELETE /api/teams/
|
||||
// {teamID} is owner-gated (requireTeamOwner), and an instance-scoped
|
||||
// credential -- the only one this controller would otherwise hold -- does
|
||||
// not satisfy that check (confirmed against source, same finding as
|
||||
// TEAM-LOOKUP.md's). A team created but never fully reconciled to Ready is
|
||||
// left orphaned server-side for a human with real owner/admin access to
|
||||
// clean up -- a known, documented limitation, not a silent gap.
|
||||
func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (ctrl.Result, error) {
|
||||
if !controllerutil.ContainsFinalizer(team, teamFinalizerName) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
if team.Status.TeamID != 0 && team.Status.CredentialsSecretRef != nil {
|
||||
if err := r.deleteTeamServerSide(ctx, team); err != nil {
|
||||
if r.Recorder != nil {
|
||||
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
|
||||
}
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: teamCredentialsSecretName(team), Namespace: r.OperatorNamespace}}
|
||||
if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
controllerutil.RemoveFinalizer(team, teamFinalizerName)
|
||||
return ctrl.Result{}, r.Update(ctx, team)
|
||||
}
|
||||
|
||||
func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error {
|
||||
srv, resolveErr := r.resolveServerRef(ctx, team)
|
||||
if resolveErr != nil {
|
||||
// The TerdutServer (or the namespace consent for it) is gone too --
|
||||
// most likely the whole install is being torn down together.
|
||||
// Nothing to delete against; proceed rather than block forever on
|
||||
// a parent that no longer exists.
|
||||
return nil
|
||||
}
|
||||
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
|
||||
if err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
newClient := r.NewClient
|
||||
if newClient == nil {
|
||||
newClient = tdclient.New
|
||||
}
|
||||
err = newClient(serviceURL(srv)).WithToken(teamKey).DeleteTeam(ctx, team.Status.TeamID)
|
||||
if statusErr, ok := errors.AsType[*tdclient.StatusError](err); ok && statusErr.Code == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// SetupWithManager sets up the controller with the Manager.
|
||||
func (r *TerdutTeamReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
if r.NewClient == nil {
|
||||
r.NewClient = tdclient.New
|
||||
}
|
||||
if r.Recorder == nil {
|
||||
r.Recorder = mgr.GetEventRecorder("terdutteam-controller")
|
||||
}
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&terdutv1alpha1.TerdutTeam{}).
|
||||
Named("terdutteam").
|
||||
Complete(r)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
"k8s.io/apimachinery/pkg/api/meta"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
var _ = Describe("TerdutTeam Controller", func() {
|
||||
const operatorNamespace = "default"
|
||||
|
||||
var (
|
||||
reconciler *TerdutTeamReconciler
|
||||
fake *fakeTerdutServer
|
||||
fakeSrv *httptest.Server
|
||||
srv *terdutv1alpha1.TerdutServer
|
||||
teamName string
|
||||
teamKey types.NamespacedName
|
||||
)
|
||||
|
||||
BeforeEach(func(ctx SpecContext) {
|
||||
fake, fakeSrv = newFakeTerdutServer()
|
||||
DeferCleanup(fakeSrv.Close)
|
||||
|
||||
srv = bootstrapReadyTerdutServer(ctx, operatorNamespace, uniqueName("ttserver"), fakeSrv.URL)
|
||||
|
||||
reconciler = &TerdutTeamReconciler{
|
||||
Client: k8sClient,
|
||||
Scheme: k8sClient.Scheme(),
|
||||
OperatorNamespace: operatorNamespace,
|
||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeSrv.URL) },
|
||||
}
|
||||
teamName = uniqueName("team")
|
||||
teamKey = types.NamespacedName{Name: teamName, Namespace: operatorNamespace}
|
||||
})
|
||||
|
||||
AfterEach(func(ctx SpecContext) {
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
if err := k8sClient.Get(ctx, teamKey, team); err == nil {
|
||||
team.Finalizers = nil
|
||||
_ = k8sClient.Update(ctx, team)
|
||||
_ = k8sClient.Delete(ctx, team)
|
||||
}
|
||||
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s.%s-team-credentials", operatorNamespace, teamName), Namespace: operatorNamespace,
|
||||
}})
|
||||
|
||||
// The TerdutServer bootstrapReadyTerdutServer created in BeforeEach
|
||||
// carries its own finalizer; clear it directly the same way, rather
|
||||
// than relying on TerdutServerReconciler to ever run again here.
|
||||
srvKey := types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}
|
||||
if err := k8sClient.Get(ctx, srvKey, srv); err == nil {
|
||||
srv.Finalizers = nil
|
||||
_ = k8sClient.Update(ctx, srv)
|
||||
_ = k8sClient.Delete(ctx, srv)
|
||||
}
|
||||
_ = k8sClient.Delete(ctx, &corev1.Secret{ObjectMeta: metav1.ObjectMeta{
|
||||
Name: fmt.Sprintf("%s.%s-instance-credentials", operatorNamespace, srv.Name), Namespace: operatorNamespace,
|
||||
}})
|
||||
})
|
||||
|
||||
createTeam := func(ctx context.Context, displayName string, serverRef terdutv1alpha1.TerdutServerRef) {
|
||||
team := &terdutv1alpha1.TerdutTeam{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: operatorNamespace},
|
||||
Spec: terdutv1alpha1.TerdutTeamSpec{ServerRef: serverRef, DisplayName: displayName},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, team)).To(Succeed())
|
||||
}
|
||||
|
||||
reconcileOnce := func(ctx context.Context) {
|
||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: teamKey})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
}
|
||||
|
||||
readyCondition := func(ctx context.Context) metav1.Condition {
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||
c := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
||||
Expect(c).NotTo(BeNil())
|
||||
return *c
|
||||
}
|
||||
|
||||
sameNSRef := func() terdutv1alpha1.TerdutServerRef {
|
||||
return terdutv1alpha1.TerdutServerRef{Name: srv.Name}
|
||||
}
|
||||
|
||||
Describe("the happy path", func() {
|
||||
It("creates the team, mints its credential, and applies rename/oidc-groups", func(ctx SpecContext) {
|
||||
createTeam(ctx, "platform", sameNSRef())
|
||||
reconcileOnce(ctx) // finalizer
|
||||
reconcileOnce(ctx) // full create+mint+apply
|
||||
|
||||
cond := readyCondition(ctx)
|
||||
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
|
||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
||||
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||
Expect(team.Status.TeamID).To(Equal(fake.teams["platform"]))
|
||||
Expect(team.Status.CredentialsSecretRef).NotTo(BeNil())
|
||||
|
||||
var credsSecret corev1.Secret
|
||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
||||
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
||||
}, &credsSecret)).To(Succeed())
|
||||
Expect(credsSecret.Data[credentialsSecretDataKey]).NotTo(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
Describe("waiting on the referenced TerdutServer", func() {
|
||||
It("reports ServerRefNotFound when the TerdutServer doesn't exist", func(ctx SpecContext) {
|
||||
createTeam(ctx, "orphan", terdutv1alpha1.TerdutServerRef{Name: "does-not-exist"})
|
||||
reconcileOnce(ctx) // finalizer
|
||||
reconcileOnce(ctx)
|
||||
|
||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonServerRefNotFound))
|
||||
})
|
||||
|
||||
It("reports WaitingForServer when the TerdutServer exists but isn't Bootstrapped yet", func(ctx SpecContext) {
|
||||
unreadyName := uniqueName("ttserver-unready")
|
||||
unready := &terdutv1alpha1.TerdutServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: unreadyName, Namespace: operatorNamespace},
|
||||
Spec: terdutv1alpha1.TerdutServerSpec{
|
||||
Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
|
||||
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "unready.example.invalid", ServicePort: 8080},
|
||||
Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, unready)).To(Succeed())
|
||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, unready) })
|
||||
|
||||
createTeam(ctx, "waiting", terdutv1alpha1.TerdutServerRef{Name: unreadyName})
|
||||
reconcileOnce(ctx) // finalizer
|
||||
reconcileOnce(ctx)
|
||||
|
||||
Expect(readyCondition(ctx).Reason).To(Equal(terdutv1alpha1.ReasonWaitingForServer))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("cross-namespace serverRef", func() {
|
||||
var otherNS string
|
||||
|
||||
BeforeEach(func(ctx SpecContext) {
|
||||
otherNS = uniqueName("ns")
|
||||
Expect(k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}})).To(Succeed())
|
||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: otherNS}}) })
|
||||
})
|
||||
|
||||
It("denies by default (From: None)", func(ctx SpecContext) {
|
||||
team := &terdutv1alpha1.TerdutTeam{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: otherNS},
|
||||
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name, Namespace: operatorNamespace},
|
||||
DisplayName: "cross-ns-denied",
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, team)).To(Succeed())
|
||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, team) })
|
||||
|
||||
crossKey := types.NamespacedName{Name: teamName, Namespace: otherNS}
|
||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey}) // finalizer
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
||||
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonRefNotPermitted))
|
||||
})
|
||||
|
||||
It("permits when the TerdutServer's allowedTeams.namespaces.from is All", func(ctx SpecContext) {
|
||||
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: srv.Name, Namespace: operatorNamespace}, srv)).To(Succeed())
|
||||
srv.Spec.AllowedTeams.Namespaces.From = "All"
|
||||
Expect(k8sClient.Update(ctx, srv)).To(Succeed())
|
||||
|
||||
team := &terdutv1alpha1.TerdutTeam{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: teamName, Namespace: otherNS},
|
||||
Spec: terdutv1alpha1.TerdutTeamSpec{
|
||||
ServerRef: terdutv1alpha1.TerdutServerRef{Name: srv.Name, Namespace: operatorNamespace},
|
||||
DisplayName: "cross-ns-allowed",
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, team)).To(Succeed())
|
||||
DeferCleanup(func() { _ = k8sClient.Delete(ctx, team) })
|
||||
|
||||
crossKey := types.NamespacedName{Name: teamName, Namespace: otherNS}
|
||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey}) // finalizer
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: crossKey})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(k8sClient.Get(ctx, crossKey, team)).To(Succeed())
|
||||
cond := meta.FindStatusCondition(team.Status.Conditions, terdutv1alpha1.ConditionTeamReady)
|
||||
// Past the gate: Adopted (the fake server has no reason to
|
||||
// reject this), definitely not RefNotPermitted.
|
||||
Expect(cond.Reason).To(Equal(terdutv1alpha1.ReasonTeamAdopted))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("adopt-on-409 recovery", func() {
|
||||
It("adopts an already-created team instead of erroring", func(ctx SpecContext) {
|
||||
fake.nextTeamID = 1
|
||||
fake.teams["platform"] = 1
|
||||
fake.teamNames[1] = "platform"
|
||||
|
||||
createTeam(ctx, "platform", sameNSRef())
|
||||
reconcileOnce(ctx) // finalizer
|
||||
reconcileOnce(ctx)
|
||||
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||
Expect(team.Status.TeamID).To(Equal(int64(1)))
|
||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||
})
|
||||
|
||||
It("adopts an already-created team-scoped service account instead of erroring", func(ctx SpecContext) {
|
||||
createTeam(ctx, "platform", sameNSRef())
|
||||
reconcileOnce(ctx) // finalizer
|
||||
|
||||
// Pre-seed the service account the mint step is about to try to
|
||||
// create, simulating an attempt that got this far before being
|
||||
// interrupted.
|
||||
fake.nextID = 1
|
||||
saName := fmt.Sprintf("terdut-team.%s.%s", operatorNamespace, teamName)
|
||||
fake.accounts[saName] = 1
|
||||
fake.keyMints[1] = 1
|
||||
|
||||
reconcileOnce(ctx)
|
||||
|
||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||
var credsSecret corev1.Secret
|
||||
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
||||
Name: team.Status.CredentialsSecretRef.Name, Namespace: operatorNamespace,
|
||||
}, &credsSecret)).To(Succeed())
|
||||
// Minted fresh (mint2), not the pre-seeded account's original
|
||||
// (never-issued-to-this-reconcile) key.
|
||||
Expect(string(credsSecret.Data[credentialsSecretDataKey])).To(Equal("instance-key-1-mint2"))
|
||||
})
|
||||
})
|
||||
|
||||
Describe("deletion", func() {
|
||||
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
|
||||
createTeam(ctx, "to-delete", sameNSRef())
|
||||
reconcileOnce(ctx)
|
||||
reconcileOnce(ctx)
|
||||
Expect(readyCondition(ctx).Status).To(Equal(metav1.ConditionTrue))
|
||||
|
||||
team := &terdutv1alpha1.TerdutTeam{}
|
||||
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
||||
teamID := team.Status.TeamID
|
||||
credsName := team.Status.CredentialsSecretRef.Name
|
||||
|
||||
Expect(k8sClient.Delete(ctx, team)).To(Succeed())
|
||||
reconcileOnce(ctx) // runs the finalizer
|
||||
|
||||
Expect(fake.teamDelete[teamID]).To(BeTrue())
|
||||
|
||||
err := k8sClient.Get(ctx, teamKey, team)
|
||||
Expect(err).To(HaveOccurred(), "the TerdutTeam itself should be gone once the finalizer clears")
|
||||
|
||||
var leftover corev1.Secret
|
||||
err = k8sClient.Get(ctx, types.NamespacedName{Name: credsName, Namespace: operatorNamespace}, &leftover)
|
||||
Expect(err).To(HaveOccurred(), "the team-credentials Secret should have been cleaned up")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
||||
|
||||
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
|
||||
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
|
||||
)
|
||||
|
||||
// Shared fixture values -- a bring-your-own DSN TerdutServer spec, reused
|
||||
// across terdutserver_controller_test.go, terdutteam_controller_test.go and
|
||||
// this file, so there's exactly one definition to keep consistent.
|
||||
const (
|
||||
testImageRepo = "example.invalid/terdut-server"
|
||||
testImageTag = "test"
|
||||
testDSN = "postgres://terdut@test-postgres:5432/terdut?sslmode=require"
|
||||
)
|
||||
|
||||
// bootstrapReadyTerdutServer creates a TerdutServer with a bring-your-own
|
||||
// DSN and drives it to Ready against fakeURL, the same three-pass sequence
|
||||
// terdutserver_controller_test.go's own happy-path test exercises directly
|
||||
// -- shared here so TerdutTeam's tests (which need a real, Ready
|
||||
// TerdutServer to resolve against) don't duplicate it.
|
||||
func bootstrapReadyTerdutServer(ctx context.Context, namespace, name, fakeURL string) *terdutv1alpha1.TerdutServer {
|
||||
GinkgoHelper()
|
||||
|
||||
reconciler := &TerdutServerReconciler{
|
||||
Client: k8sClient,
|
||||
Scheme: k8sClient.Scheme(),
|
||||
OperatorNamespace: namespace,
|
||||
NewClient: func(string) *tdclient.Client { return tdclient.New(fakeURL) },
|
||||
}
|
||||
objKey := types.NamespacedName{Name: name, Namespace: namespace}
|
||||
|
||||
srv := &terdutv1alpha1.TerdutServer{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
|
||||
Spec: terdutv1alpha1.TerdutServerSpec{
|
||||
Image: terdutv1alpha1.ImageSpec{Repository: testImageRepo, Tag: testImageTag},
|
||||
Networking: terdutv1alpha1.NetworkingSpec{Hostname: "terdut.example.invalid", ServicePort: 8080},
|
||||
Database: terdutv1alpha1.DatabaseSpec{DSN: testDSN},
|
||||
},
|
||||
}
|
||||
Expect(k8sClient.Create(ctx, srv)).To(Succeed())
|
||||
|
||||
_, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // finalizer
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // Deployment/Service
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
var deploy appsv1.Deployment
|
||||
Expect(k8sClient.Get(ctx, objKey, &deploy)).To(Succeed())
|
||||
deploy.Status.ReadyReplicas = 1
|
||||
deploy.Status.Replicas = 1
|
||||
Expect(k8sClient.Status().Update(ctx, &deploy)).To(Succeed())
|
||||
|
||||
_, err = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: objKey}) // bootstrap
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
Expect(k8sClient.Get(ctx, objKey, srv)).To(Succeed())
|
||||
Expect(srv.Status.CredentialsSecretRef).NotTo(BeNil(), "test setup: TerdutServer %s/%s did not reach Bootstrapped", namespace, name)
|
||||
return srv
|
||||
}
|
||||
|
||||
// uniqueNameCounter backs uniqueName. GinkgoRandomSeed()/GinkgoParallelProcess()
|
||||
// are constants for the whole suite run, not per-spec -- an earlier version
|
||||
// of this helper used them and collided across every spec that called it
|
||||
// more than once; an atomic counter is genuinely unique per call instead.
|
||||
var uniqueNameCounter atomic.Int64
|
||||
|
||||
// uniqueName returns a Kubernetes-object-safe name unique to this call.
|
||||
func uniqueName(prefix string) string {
|
||||
return fmt.Sprintf("%s-%d", prefix, uniqueNameCounter.Add(1))
|
||||
}
|
||||
Reference in New Issue
Block a user