Authenticate with a seeded operator key; fold escalation and switches into TerdutTeam
Credentials: the TerdutServer controller generates <name>-operator-key in the server's own namespace (owned by it) and hands it to the pods as TERDUT_OPERATOR_KEY; the server creates its instance-scoped account from it at every start. A replaced Secret rolls the pods. The bootstrap handshake, the checkpoint Secret, per-team service accounts and credentials Secrets, BootstrapStateLost and credentials.deletionPolicy are gone. CRDs: TerdutServer, TerdutTeam and TerdutAlertSource. TerdutEscalationRule and TerdutDeadmanSwitch become spec.escalation and spec.deadmanSwitches[] on the team (matched by name, extras removed); team invites are removed. A team is created under the identity <namespace>/<name> (external_id), so a retry, a lost status or a deleted team heal by repeating the same call, and a display name owned by another team is TeamNameTaken instead of an adoption. The server resolves escalation usernames (UnknownUser condition). OIDC claim names and trustEmail are spec fields. Fixes: query values are URL-escaped; every delete treats 404 as success; deleting a team no longer depends on allowedTeams consent; a switch or integration deleted on the server is recreated; unnamed switches take the CR's name. Cleanup: scaffold e2e test, AGENTS.md, devcontainer, unused config/ pieces and Client.Version() removed; DESIGN.md, README, ROADMAP and the demo (run-demo.sh, manifests) rewritten for the new design. Secret RBAC stays cluster-wide, now stated in DESIGN.md section 9. Claude-Session: https://claude.ai/code/session_016mBLURvJoMuUEr9cB2RpUN
This commit is contained in:
+28
-277
@@ -10,6 +10,7 @@ package tdclient
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -57,6 +58,16 @@ type StatusError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
// ignoreNotFound turns a 404 into success. Every DELETE here is idempotent: the
|
||||
// thing already being gone is the outcome the caller wanted, and a finalizer
|
||||
// that failed on it could never be removed.
|
||||
func ignoreNotFound(err error) error {
|
||||
if se, ok := errors.AsType[*StatusError](err); ok && se.Code == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *StatusError) Error() string {
|
||||
if e.Message != "" {
|
||||
return fmt.Sprintf("server returned %d: %s", e.Code, e.Message)
|
||||
@@ -116,187 +127,23 @@ func (c *Client) do(req *http.Request, out any) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version calls GET /api/version — unauthenticated, per terdut-server's own
|
||||
// router.go comment ("a client deciding whether it can talk to this server —
|
||||
// terdut-tui, terdut-operator — needs to ask before it holds a credential
|
||||
// for it"). Used here purely as a reachability probe: a bad endpoint fails
|
||||
// here, clearly, rather than on whatever the controller tries first.
|
||||
func (c *Client) Version(ctx context.Context) (string, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/version", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var v struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := c.do(req, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v.Version, nil
|
||||
}
|
||||
|
||||
// APIKey is the raw key a bootstrap or service-account-key mint hands back —
|
||||
// the one moment its value exists outside the request that generated it.
|
||||
// Mirrors terdut-server's models.APIKey/models.ServiceAccountKey shape
|
||||
// (internal/models in that repo) for the fields this client actually reads.
|
||||
type APIKey struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// BootstrapResult is /api/bootstrap's 201 response body.
|
||||
type BootstrapResult struct {
|
||||
User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
} `json:"user"`
|
||||
APIKey APIKey `json:"api_key"`
|
||||
}
|
||||
|
||||
// Bootstrap calls POST /api/bootstrap — unauthenticated, single-shot per
|
||||
// install (internal/api/users.go's handleBootstrap in terdut-server:
|
||||
// gated on SELECT COUNT(*) FROM users). Returns the raw admin key directly;
|
||||
// DESIGN.md §6 has the controller use it for exactly one further call
|
||||
// (CreateServiceAccount) and discard it, never storing it as the lasting
|
||||
// credential.
|
||||
//
|
||||
// A StatusError with Code 403 means this install already has a user —
|
||||
// per this operator's design (DESIGN.md §1), that only happens if this
|
||||
// exact TerdutServer's own controller already won this race on an earlier,
|
||||
// interrupted reconcile; see the checkpoint-Secret handling in the
|
||||
// controller, not a retry loop here.
|
||||
func (c *Client) Bootstrap(ctx context.Context, username, email string) (*BootstrapResult, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/bootstrap", map[string]string{
|
||||
"username": username,
|
||||
"email": email,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result BootstrapResult
|
||||
if err := c.do(req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ServiceAccount mirrors terdut-server's models.ServiceAccount
|
||||
// (internal/models/service_account.go), minus fields this client never
|
||||
// reads.
|
||||
type ServiceAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// CreateServiceAccountResult is POST /api/service-accounts' 201 response.
|
||||
type CreateServiceAccountResult struct {
|
||||
ServiceAccount ServiceAccount `json:"service_account"`
|
||||
Key APIKey `json:"key"`
|
||||
}
|
||||
|
||||
// CreateInstanceServiceAccount calls POST /api/service-accounts with
|
||||
// scope "instance", authenticated with c's current token (the raw admin key
|
||||
// from Bootstrap, for the operator's own first-ever call). A StatusError
|
||||
// with Code 409 means a prior, interrupted attempt already created this
|
||||
// name — DESIGN.md §6's adopt-rather-than-error rule: the caller should
|
||||
// fall back to GetServiceAccountByName + CreateServiceAccountKey, not treat
|
||||
// this as a hard failure.
|
||||
func (c *Client) CreateInstanceServiceAccount(ctx context.Context, name string) (*CreateServiceAccountResult, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]string{
|
||||
fieldName: name,
|
||||
"scope": "instance",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result CreateServiceAccountResult
|
||||
if err := c.do(req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// CreateTeamServiceAccount calls POST /api/service-accounts with scope
|
||||
// "team" for teamID, authenticated with c's current token -- the
|
||||
// TerdutServer's instance-scoped credential, per DESIGN.md §6 point 3: an
|
||||
// instance-scoped caller may mint a team-scoped account against any team
|
||||
// (internal/api/service_accounts.go's handleCreateServiceAccount,
|
||||
// confirmed against source), which is what lets TerdutTeam's own
|
||||
// controller do this without ever touching a human credential. Same
|
||||
// adopt-on-409 contract as CreateInstanceServiceAccount.
|
||||
func (c *Client) CreateTeamServiceAccount(ctx context.Context, name string, teamID int64) (*CreateServiceAccountResult, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/service-accounts", map[string]any{
|
||||
fieldName: name,
|
||||
"scope": "team",
|
||||
"team_id": teamID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result CreateServiceAccountResult
|
||||
if err := c.do(req, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetServiceAccountByName calls GET /api/service-accounts?name=... --
|
||||
// authenticated (terdut-server's AuthMiddleware hard-rejects any
|
||||
// unauthenticated request before this endpoint's own, more permissive
|
||||
// internal check ever runs; DESIGN.md §6). Returns nil, nil if nothing
|
||||
// matches, not an error -- the server's own distinction between "found
|
||||
// nothing" and "the call failed".
|
||||
func (c *Client) GetServiceAccountByName(ctx context.Context, name string) (*ServiceAccount, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/service-accounts?name="+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var accounts []ServiceAccount
|
||||
if err := c.do(req, &accounts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(accounts) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &accounts[0], nil
|
||||
}
|
||||
|
||||
// CreateServiceAccountKey calls POST /api/service-accounts/{id}/keys to
|
||||
// mint an additional key on an existing account -- rotation (DESIGN.md §6
|
||||
// point 6), and the adopt-on-409 recovery path in point 1.
|
||||
func (c *Client) CreateServiceAccountKey(ctx context.Context, serviceAccountID int64, name string) (*APIKey, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost,
|
||||
fmt.Sprintf("/api/service-accounts/%d/keys", serviceAccountID),
|
||||
map[string]string{fieldName: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var key APIKey
|
||||
if err := c.do(req, &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
// Team mirrors terdut-server's models.Team (internal/models/team.go), minus
|
||||
// Role/Source, which are only ever populated for a human caller's own
|
||||
// membership and never apply to a service account's view of a team.
|
||||
type Team struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ExternalID *string `json:"external_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateTeam calls POST /api/teams, authenticated with c's current token --
|
||||
// the TerdutServer's instance-scoped credential (DESIGN.md §6 point 3). A
|
||||
// StatusError with Code 409 means a prior, interrupted attempt already
|
||||
// created this name — GetTeamByName (TEAM-LOOKUP.md) is the adopt-rather-
|
||||
// than-error recovery, the same contract CreateInstanceServiceAccount has.
|
||||
func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams", map[string]string{fieldName: name})
|
||||
// CreateTeam calls POST /api/teams with an external_id, which makes it
|
||||
// idempotent: a team that already carries that id is returned (200) instead of
|
||||
// created (201), so a client that crashed before recording the id finds its own
|
||||
// team again. A StatusError with Code 409 means the name belongs to a different
|
||||
// team. Needs an instance-scoped credential.
|
||||
func (c *Client) CreateTeam(ctx context.Context, name, externalID string) (*Team, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, "/api/teams",
|
||||
map[string]string{fieldName: name, "external_id": externalID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -307,24 +154,6 @@ func (c *Client) CreateTeam(ctx context.Context, name string) (*Team, error) {
|
||||
return &team, nil
|
||||
}
|
||||
|
||||
// GetTeamByName calls GET /api/teams?name=... (TEAM-LOOKUP.md) — open to
|
||||
// any authenticated caller, not just the team's own members. Returns nil,
|
||||
// nil on no match, not an error.
|
||||
func (c *Client) GetTeamByName(ctx context.Context, name string) (*Team, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/teams?name="+name, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var teams []Team
|
||||
if err := c.do(req, &teams); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(teams) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return &teams[0], nil
|
||||
}
|
||||
|
||||
// RenameTeam calls PUT /api/teams/{teamID} — owner-gated server-side
|
||||
// (requireTeamOwner), so c must hold this team's own team-scoped
|
||||
// credential, not the instance-scoped one CreateTeam used.
|
||||
@@ -346,7 +175,7 @@ func (c *Client) DeleteTeam(ctx context.Context, teamID int64) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
return ignoreNotFound(c.do(req, nil))
|
||||
}
|
||||
|
||||
// SetTeamOIDCGroups calls PUT /api/teams/{teamID}/oidc-groups — owner-gated,
|
||||
@@ -362,46 +191,15 @@ func (c *Client) SetTeamOIDCGroups(ctx context.Context, teamID int64, memberGrou
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
// User mirrors terdut-server's models.User, minus fields this client never
|
||||
// reads.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// GetUserByUsername calls GET /api/users and finds the one matching exactly
|
||||
// -- confirmed open to any authenticated caller, not gated by team
|
||||
// membership or admin (internal/api/router.go's own comment: "readable by
|
||||
// anyone signed in"), so the team-scoped credential a TerdutEscalationRule's
|
||||
// controller already holds is enough. There is no server-side filter, so
|
||||
// this always fetches the whole list; terdut-server's own query has no
|
||||
// pagination either (confirmed against source), so this matches what the
|
||||
// server itself considers an acceptable cost. Returns nil, nil on no match.
|
||||
func (c *Client) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodGet, "/api/users", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var users []User
|
||||
if err := c.do(req, &users); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, u := range users {
|
||||
if u.Username == username {
|
||||
return &u, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// EscalationTargetRequest/EscalationLevelRequest/SetEscalationRequest mirror
|
||||
// terdut-server's escalationTargetJSON/escalationLevelJSON/escalationJSON
|
||||
// (internal/api/escalation.go) -- the PUT body, not the richer GET response
|
||||
// (escalationView), which this client never needs to decode since the
|
||||
// controller always computes its own desired state fresh from spec.
|
||||
type EscalationTargetRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
// Username names the person for a "user" target; the server resolves it.
|
||||
Username string `json:"username,omitempty"`
|
||||
}
|
||||
|
||||
type EscalationLevelRequest struct {
|
||||
@@ -498,7 +296,7 @@ func (c *Client) DeleteDeadmanSwitch(ctx context.Context, teamID, switchID int64
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
return ignoreNotFound(c.do(req, nil))
|
||||
}
|
||||
|
||||
// Integration mirrors terdut-server's models.Integration, minus
|
||||
@@ -564,52 +362,5 @@ func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID in
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
}
|
||||
|
||||
// Invite is a standing link into a team (POST /api/teams/{teamID}/invites'
|
||||
// own response shape). URL carries the raw token exactly once, at creation
|
||||
// -- terdut-server never shows it again (same one-time-shown shape as an
|
||||
// integration's webhook key) -- so a caller that needs it later has to have
|
||||
// kept this response, not re-fetched it.
|
||||
type Invite struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamID int64 `json:"team_id"`
|
||||
Role string `json:"role"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
MaxUses int64 `json:"max_uses"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated
|
||||
// (requireTeamOwner), so c must hold this team's own team-scoped
|
||||
// credential, which already satisfies that check via its synthetic owner
|
||||
// membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling
|
||||
// needed: unlike a team or a service account, an invite has no unique name
|
||||
// to collide on -- every call mints a brand new row.
|
||||
func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) {
|
||||
req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID),
|
||||
map[string]any{"role": role, "max_uses": maxUses})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var inv Invite
|
||||
if err := c.do(req, &inv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &inv, nil
|
||||
}
|
||||
|
||||
// RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same
|
||||
// credential requirement as CreateInvite. A 404 (already revoked, or never
|
||||
// existed) is the caller's to treat as success if it wants to, the same way
|
||||
// DeleteTeam's own 404 handling works -- this method itself just reports
|
||||
// whatever terdut-server said.
|
||||
func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error {
|
||||
req, err := c.newRequest(ctx, http.MethodDelete,
|
||||
fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.do(req, nil)
|
||||
return ignoreNotFound(c.do(req, nil))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user