Files
moby/daemon/extensions.go
Paweł Gronowski 404400e4cf daemon/extensions: Support exposable services
Allow extensions to offer typed points through servicev0.Offer and leave
gRPC registration to the host.

The server registration list is empty for now; no points are offerable
yet.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
2026-11-05 20:47:13 +01:00

187 lines
6.4 KiB
Go

package daemon
import (
"context"
"errors"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"slices"
"github.com/containerd/log"
"github.com/moby/extensions"
"github.com/moby/extensions/grpcproxy"
"github.com/moby/extensions/host"
"github.com/moby/moby/v2/daemon/config"
"github.com/moby/moby/v2/daemon/internal/rootless"
servicegrpcv0 "github.com/moby/moby/v2/extpoints/servicegrpc/v0"
"github.com/moby/moby/v2/pkg/homedir"
"google.golang.org/grpc"
)
// newExtensionHost builds the daemon's extension host.
func (daemon *Daemon) newExtensionHost(ctx context.Context) (*host.Host, error) {
cfg := daemon.Config()
runtimeDir := extensionRuntimeDir(&cfg)
if err := os.MkdirAll(runtimeDir, 0o700); err != nil {
return nil, fmt.Errorf("create extension runtime directory: %w", err)
}
if err := os.Chmod(runtimeDir, 0o700); err != nil {
return nil, fmt.Errorf("set permissions on extension runtime directory: %w", err)
}
// A crashed daemon leaves its API socket behind. Opening the socket
// replaces it, but a daemon whose extensions never use the API would
// otherwise keep the dead socket around.
if err := os.Remove(extensionAPISocket(&cfg)); err != nil && !errors.Is(err, fs.ErrNotExist) {
log.G(ctx).WithError(err).Warn("failed to remove stale extension API socket")
}
defaults := slices.Concat(builtinExtensions, daemon.daemonExtensions())
exts, err := selectBuiltinExtensions(cfg.Extensions, defaults, daemon.optionalExtensions())
if err != nil {
return nil, err
}
return host.New(ctx,
host.WithRuntimeDir(runtimeDir),
host.WithExtensions(exts...),
host.WithDirs(extensionDirs(&cfg)...),
host.WithClientProviders(clientProviders()...),
host.WithProviderPolicy(host.PointPolicyFunc(func(extensions.ExtensionIdentity, extensions.PointID) host.PointPolicyResult {
return host.Allow()
})),
host.WithDependencyProviders(dependencyProviders()...),
host.WithPointServers(offeredPointServers()...),
host.WithExtensionConfig(extensionConfig(&cfg)),
)
}
// extensionRuntimeDir holds the extension sockets.
// It is private to the daemon user.
func extensionRuntimeDir(cfg *config.Config) string {
return filepath.Join(cfg.ExecRoot, "extensions")
}
// extensionAPISocket is the path of the API socket for extensions.
func extensionAPISocket(cfg *config.Config) string {
return filepath.Join(extensionRuntimeDir(cfg), "api.sock")
}
// extensionAPIEndpoint provides the daemon.api point.
// It opens the API socket for extensions when an extension first asks for the
// endpoint, so daemons without such extensions expose no extra API endpoint.
// The socket is separate from the configured hosts, which may be TLS-only TCP
// or socket-activated and so unusable by extensions.
func (daemon *Daemon) extensionAPIEndpoint(ctx context.Context) (string, error) {
cfg := daemon.Config()
path := extensionAPISocket(&cfg)
if err := daemon.apiSockets.Open(ctx, path); err != nil {
return "", err
}
return "unix://" + path, nil
}
// StartAPISockets serves h on the API sockets the daemon creates for its own
// consumers, such as extensions.
func (daemon *Daemon) StartAPISockets(h http.Handler) {
daemon.apiSockets.Start(h)
}
// extensionConfig converts daemon.json's extensions.configs entries to host form.
func extensionConfig(cfg *config.Config) map[extensions.ExtensionID]extensions.Config {
if len(cfg.Extensions.Configs) == 0 {
return nil
}
out := make(map[extensions.ExtensionID]extensions.Config, len(cfg.Extensions.Configs))
for _, extensionConfig := range cfg.Extensions.Configs {
out[extensionConfig.ID] = extensionConfig.Config
}
return out
}
// extensionDirs are the directories scanned for out-of-process extension
// binaries: the ones configured with --extension-dir, or the default location
// when none are configured.
func extensionDirs(cfg *config.Config) []string {
if len(cfg.Extensions.Dirs) > 0 {
return cfg.Extensions.Dirs
}
dir, err := defaultExtensionDir()
if err != nil {
// Only rootless without a home directory reaches here; without a default
// the daemon simply loads no extensions rather than failing to start.
log.G(context.TODO()).WithError(err).Debug("extensions: no default directory")
return nil
}
return []string{dir}
}
// defaultExtensionDir is the standard location for extension binaries:
// /usr/libexec/docker/moby-extensions, or the rootless equivalent under the
// user's libexec home.
func defaultExtensionDir() (string, error) {
libexecDir := "/usr/libexec"
if rootless.RunningWithRootlessKit() {
var err error
libexecDir, err = homedir.GetLibexecHome()
if err != nil {
return "", err
}
}
return filepath.Join(libexecDir, "docker", "moby-extensions"), nil
}
// ExposeExtensionServices publishes service.grpc services and points offered
// through service.v0 on the API socket.
// In-process services are registered on gs; out-of-process services are proxied.
// Service-name collisions fail startup.
func (daemon *Daemon) ExposeExtensionServices(gs *grpc.Server) (*grpcproxy.Proxy, error) {
if daemon.extensionHost == nil {
return nil, nil
}
reserved := make(map[string]struct{})
for name := range gs.GetServiceInfo() {
reserved[name] = struct{}{}
}
inproc, err := servicegrpcv0.Collect(daemon.extensionHost)
if err != nil {
return nil, err
}
// Include offered points in the service-name collision check.
var offered offeredServices
daemon.extensionHost.RegisterInProcessServices(&offered)
inproc = append(inproc, offered...)
for _, svc := range inproc {
if _, taken := reserved[svc.Name]; taken {
return nil, fmt.Errorf("in-process extension cannot expose gRPC service %q: it is already served", svc.Name)
}
reserved[svc.Name] = struct{}{}
}
for _, svc := range inproc {
gs.RegisterService(svc.Desc, svc.Impl)
}
var backends []grpcproxy.Backend
for ext, names := range daemon.extensionHost.PublishedServicesForPoint(servicegrpcv0.Point.ID()) {
if conn, ok := daemon.extensionHost.Conn(ext); ok {
backends = append(backends, grpcproxy.Backend{ID: string(ext), Conn: conn, Services: names})
}
}
routes, err := grpcproxy.BuildRoutes(backends, reserved)
if err != nil {
return nil, err
}
if len(routes) == 0 {
return nil, nil
}
return grpcproxy.New(routes), nil
}
// offeredServices collects in-process services for validation before registration.
type offeredServices []servicegrpcv0.Service
func (s *offeredServices) RegisterService(desc *grpc.ServiceDesc, impl any) {
*s = append(*s, servicegrpcv0.Service{Name: desc.ServiceName, Desc: desc, Impl: impl})
}