Files
terdut-operator/internal/controller/childref.go
T
Niklas Ye fb9e6a38dc
CI / test (push) Has been cancelled
Stage 3: TerdutEscalationRule + TerdutDeadmanSwitch
Both child CRDs resolve their own teamRef -> TerdutTeam.status via the new
shared resolveTeamAndClient helper (childref.go), never chaining up to
TerdutServer (DESIGN.md §5) -- TerdutTeam.status.serverEndpoint, added in
this same stage, is what makes that literally true.

TerdutEscalationRule: one PUT /api/teams/{id}/escalation per reconcile
(an upsert server-side, confirmed against source), resolving each "user"
target's username to a user_id via GET /api/users first and reporting
Ready: False, reason: UnknownUser if it doesn't resolve. No DELETE exists
for this resource, so its delete path PUTs an empty policy as the closest
available undo.

TerdutDeadmanSwitch: real create/update-in-place/delete, using
terdut-server v0.33.0's PUT (added specifically for this operator). No
unique-name constraint server-side, so idempotent-create here is
GET-list-and-match-by-name rather than adopt-on-409.

Extends tdclient with User/GetUserByUsername, the escalation request types
+ SetEscalation, and DeadmanSwitch + its CRUD methods. Also folds
ConditionTeamReady into the single shared ConditionReady constant, since
both were literally "Ready" and Stage 3 would otherwise have needed a
third same-valued constant.

internal/controller/terdutserver_controller_test.go's fakeTerdutServer
grows GET /api/users, PUT .../escalation, and the full dead man's switch
collection/item routes, replacing the old parseTeamPath/handleTeamByID
pair with a more general parseTeamSubPath/handleTeamSubPath dispatcher
that still covers every existing Stage 1/2 route unchanged.

make fmt lint test build all clean; envtest coverage for
internal/controller: 50.5% -> 71.7%.
2026-10-01 13:50:08 +02:00

55 lines
2.0 KiB
Go

package controller
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"sigs.k8s.io/controller-runtime/pkg/client"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// childError carries a condition reason/message, the same role teamError
// and databaseError play for their own controllers: an expected,
// requeue-and-retry outcome, not a reconcile failure.
type childError struct {
reason string
message string
}
func (e *childError) Error() string { return e.message }
// resolveTeamAndClient implements DESIGN.md §5's "every child resolves its
// own teamRef -> TerdutTeam.status, never chains up to TerdutServer"
// rule -- shared by TerdutEscalationRule and TerdutDeadmanSwitch, which
// both need exactly this and nothing else to call terdut-server's API.
func resolveTeamAndClient(
ctx context.Context, c client.Client, operatorNamespace, namespace string,
ref terdutv1alpha1.TerdutTeamRef, newClient func(string) *tdclient.Client,
) (*terdutv1alpha1.TerdutTeam, *tdclient.Client, *childError) {
var team terdutv1alpha1.TerdutTeam
if err := c.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &team); err != nil {
if apierrors.IsNotFound(err) {
return nil, nil, &childError{
reason: terdutv1alpha1.ReasonTeamRefNotFound,
message: fmt.Sprintf("TerdutTeam %q not found in namespace %q", ref.Name, namespace),
}
}
return nil, nil, &childError{reason: terdutv1alpha1.ReasonTeamRefNotFound, message: err.Error()}
}
if team.Status.CredentialsSecretRef == nil || team.Status.TeamID == 0 {
return nil, nil, &childError{
reason: terdutv1alpha1.ReasonWaitingForTeam,
message: fmt.Sprintf("TerdutTeam %q is not Ready yet", ref.Name),
}
}
teamKey, err := readOperatorSecret(ctx, c, operatorNamespace, team.Status.CredentialsSecretRef)
if err != nil {
return nil, nil, &childError{reason: terdutv1alpha1.ReasonWaitingForTeam, message: err.Error()}
}
return &team, newClient(team.Status.ServerEndpoint).WithToken(teamKey), nil
}