Files
livekit/pkg/service/agentendpointscope.go
T
Paul Wells 1944cb495d agent endpoints: scope the registry, let the embedder own tenancy
The registry was doing three jobs at once: fencing worker epochs by id,
listing a deployment's candidates, and holding its merged route table. The
last two are per-deployment state, so it keyed them on
(api key, agent name, deployment) and grew a tenancy concept that only an
embedder can actually define. Cloud has to lie to it, passing a project id
in a field named APIKey.

Split them. Scope is one deployment's serving state and stores no identity
at all; whoever embeds the package keys a map of scopes however its own
tenancy works, and hands the front a resolved one. Registry keeps only the
worker-id fence, which is genuinely node-wide: worker ids are server-issued,
so an epoch is superseded wherever it was scoped.

The front loses its registry, its SingleKeyFallback and FallbackRequest: the
resolver now returns the scope and a fallback already curried on the
deployment, plus an ok that carries the 401-vs-503 split the empty api key
used to encode. routeTable drops its key and takes the scope's logger, so
identity is curried in rather than stored.

pkg/service takes ownership of the "api key is the tenant" rule, which is
true there and nowhere else, and of releasing a scope once nothing holds it.
Behavior is unchanged, including serving public routes to an unauthenticated
caller when one configured key or one attached tenant makes the key
unambiguous.
2026-09-16 08:47:37 -07:00

118 lines
3.5 KiB
Go

// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service
import (
"sync"
"github.com/livekit/protocol/logger"
"github.com/livekit/livekit-server/pkg/agent/endpoint"
)
// endpointScopeKey is this server's tenancy for agent HTTP endpoints: the api
// key is the project identity. The endpoint package holds no tenancy identity of
// its own, so this is the one place that rule is written.
type endpointScopeKey struct {
apiKey string
agentName string
deployment string
}
func newEndpointScopeKey(apiKey, agentName, deployment string) endpointScopeKey {
return endpointScopeKey{apiKey, agentName, endpoint.NormalizeDeployment(deployment)}
}
// EndpointScopes maps that tenancy onto the endpoint package's scopes, one per
// key, held for as long as a worker holds it.
type EndpointScopes struct {
logger logger.Logger
mu sync.Mutex
scopes map[endpointScopeKey]*endpointScopeEntry
}
// endpointScopeEntry refcounts a scope by the registrations that hold it: a
// scope must outlive a worker that has acquired it but not yet registered, or a
// concurrent release would strand the new registration in an orphaned scope.
type endpointScopeEntry struct {
scope *endpoint.Scope
n int
}
func NewEndpointScopes() *EndpointScopes {
return &EndpointScopes{
logger: logger.GetLogger().WithComponent("agents.endpoint"),
scopes: make(map[endpointScopeKey]*endpointScopeEntry),
}
}
// acquire returns the key's scope, creating it on first use. Every acquire must
// be matched by a release.
func (s *EndpointScopes) acquire(k endpointScopeKey) *endpoint.Scope {
s.mu.Lock()
defer s.mu.Unlock()
e := s.scopes[k]
if e == nil {
e = &endpointScopeEntry{scope: endpoint.NewScope(s.logger.WithValues(
"apiKey", k.apiKey, "agentName", k.agentName, "deployment", k.deployment))}
s.scopes[k] = e
}
e.n++
return e.scope
}
func (s *EndpointScopes) release(k endpointScopeKey) {
s.mu.Lock()
defer s.mu.Unlock()
e := s.scopes[k]
if e == nil {
return
}
if e.n--; e.n <= 0 {
delete(s.scopes, k)
}
}
// Scope returns the deployment's serving state, or nil when no worker holds it -
// which the front reads as "no worker for this deployment".
func (s *EndpointScopes) Scope(apiKey, agentName, deployment string) *endpoint.Scope {
s.mu.Lock()
defer s.mu.Unlock()
if e := s.scopes[newEndpointScopeKey(apiKey, agentName, deployment)]; e != nil {
return e.scope
}
return nil
}
// SingleKey returns the api key when every scope on this node belongs to one -
// the resolution for unauthenticated requests to public endpoints. It is sound
// only because a guessed api key confers no access: the front still serves such
// a request only against routes marked public. ok is false when zero or multiple
// keys are present.
func (s *EndpointScopes) SingleKey() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
var key string
for k := range s.scopes {
if key == "" {
key = k.apiKey
} else if key != k.apiKey {
return "", false
}
}
return key, key != ""
}