a0ea13955e
The actual fix for the human-onboarding gap niklas/terdut-server#23 found -- not a terdut-server change at all. A team-scoped credential is already owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites (requireTeamOwner's synthetic-membership mechanism, ratified not accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption bypasses signup_mode entirely -- this TerdutTeam controller just never grew a feature to use either fact. New spec.invite{enabled, role (member|owner, default member), maxUses (1-100, default 1)} and status.inviteSecretRef. The Secret lives in the TerdutTeam's OWN namespace, not the operator's: unlike status.credentialsSecretRef (a durable, high-privilege credential, kept operator-side per DESIGN.md §6), an invite is bounded and limited-use, meant for this namespace's own human operators to read and hand out -- same precedent as TerdutAlertSource's status.webhookURLSecretRef, same- namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects it automatically. internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled, refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the Secret's own stored expiresAt, no extra server round-trip per reconcile), revokes server-side and deletes the Secret when flipped back to false. A lost invite Secret is silently re-minted rather than treated as unrecoverable the way TerdutAlertSource's webhook key is -- nothing external holds a durable dependency on one specific invite link staying stable, it's read once by one human and handed out. New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint into the team's own namespace, refresh-before-expiry, revoke-on-disable (internal/controller/terdutteam_controller_test.go's new "spec.invite" Describe block), plus the fake server growing invite support (terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was split further (deadman switches into their own handleDeadmanSubPath, matching the existing handleIntegrationSubPath precedent) to stay under golangci-lint's gocyclo threshold with the new route added. examples/demo updated to prove this end to end: 02-team-platform.yaml turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the psql signup_mode flip + a direct team_members INSERT) are replaced by redeem_platform_invite (reads status.inviteSecretRef, a real POST /api/signup with the invite token) and join_payments_team (POST /api/teams/{teamID}/members using Payments' own credential and alice's user id resolved via GET /api/users, deliberately not given its own spec.invite, so the demo shows both onboarding paths this feature unlocks) -- zero kubectl exec/psql calls remain anywhere in the script. README.md's "First login" section rewritten to match; it no longer documents the admin-token curl call that 403s against current terdut-server (niklas/terdut-server#23). Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix for terdut-operator#3) being released before this is deployed for real -- not required to build or test this change itself, since the envtest fake never modeled that authorization gap to begin with.
363 lines
14 KiB
Go
363 lines
14 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"time"
|
|
|
|
. "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, 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.ConditionReady)
|
|
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: testRefNotFoundName})
|
|
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.ConditionReady)
|
|
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.ConditionReady)
|
|
// 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("spec.invite", func() {
|
|
It("mints a link into the TerdutTeam's own namespace, not the operator's", func(ctx SpecContext) {
|
|
createTeam(ctx, "platform", sameNSRef())
|
|
reconcileOnce(ctx) // finalizer
|
|
reconcileOnce(ctx) // create+mint+apply
|
|
|
|
team := &terdutv1alpha1.TerdutTeam{}
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
team.Spec.Invite.Enabled = true
|
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
reconcileOnce(ctx)
|
|
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
Expect(team.Status.InviteSecretRef).NotTo(BeNil())
|
|
|
|
var secret corev1.Secret
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{
|
|
Name: team.Status.InviteSecretRef.Name, Namespace: team.Namespace,
|
|
}, &secret)).To(Succeed())
|
|
Expect(string(secret.Data[inviteSecretURLKey])).To(ContainSubstring("invite="))
|
|
Expect(string(secret.Data[inviteSecretInviteIDKey])).To(Equal("1"))
|
|
|
|
inv := fake.invites[team.Status.TeamID][1]
|
|
Expect(inv.Role).To(Equal("member"), "default role")
|
|
Expect(inv.MaxUses).To(Equal(int64(1)), "default max uses")
|
|
})
|
|
|
|
It("refreshes a link that's within a day of terdut-server's 7-day TTL", func(ctx SpecContext) {
|
|
createTeam(ctx, "platform", sameNSRef())
|
|
reconcileOnce(ctx)
|
|
reconcileOnce(ctx)
|
|
|
|
team := &terdutv1alpha1.TerdutTeam{}
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
team.Spec.Invite.Enabled = true
|
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
reconcileOnce(ctx)
|
|
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
firstSecretName := team.Status.InviteSecretRef.Name
|
|
var secret corev1.Secret
|
|
Expect(k8sClient.Get(ctx, types.NamespacedName{Name: firstSecretName, Namespace: team.Namespace}, &secret)).To(Succeed())
|
|
|
|
// Simulate the stored link being within the refresh window of
|
|
// expiry, the way it genuinely would be six days from now,
|
|
// without the test waiting six days.
|
|
secret.Data[inviteSecretExpiresAtKey] = []byte(time.Now().Add(12 * time.Hour).Format(time.RFC3339)) // inside inviteRefreshWindow
|
|
Expect(k8sClient.Update(ctx, &secret)).To(Succeed())
|
|
|
|
reconcileOnce(ctx)
|
|
|
|
Expect(fake.inviteDelete[1]).To(BeTrue(), "the stale invite should have been revoked")
|
|
Expect(fake.invites[team.Status.TeamID]).To(HaveKey(int64(2)), "a replacement should have been minted")
|
|
})
|
|
|
|
It("revokes the invite when spec.invite.enabled flips back to false", func(ctx SpecContext) {
|
|
createTeam(ctx, "platform", sameNSRef())
|
|
reconcileOnce(ctx)
|
|
reconcileOnce(ctx)
|
|
|
|
team := &terdutv1alpha1.TerdutTeam{}
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
team.Spec.Invite.Enabled = true
|
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
reconcileOnce(ctx)
|
|
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
secretName := team.Status.InviteSecretRef.Name
|
|
team.Spec.Invite.Enabled = false
|
|
Expect(k8sClient.Update(ctx, team)).To(Succeed())
|
|
reconcileOnce(ctx)
|
|
|
|
Expect(fake.inviteDelete[1]).To(BeTrue())
|
|
Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed())
|
|
Expect(team.Status.InviteSecretRef).To(BeNil())
|
|
|
|
var secret corev1.Secret
|
|
err := k8sClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: team.Namespace}, &secret)
|
|
Expect(err).To(HaveOccurred(), "the invite Secret should have been deleted")
|
|
})
|
|
})
|
|
|
|
Describe("deletion", func() {
|
|
It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) {
|
|
createTeam(ctx, "to-delete", sameNSRef())
|
|
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")
|
|
})
|
|
})
|
|
})
|