mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
Adds `coder_ai_gateway_` to standalone Gateway metics to match embedded case.
514 lines
18 KiB
Go
514 lines
18 KiB
Go
//go:build !slim
|
|
|
|
package cli
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
|
semconv "go.opentelemetry.io/otel/semconv/v1.14.0"
|
|
"go.opentelemetry.io/otel/semconv/v1.14.0/httpconv"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/aibridge"
|
|
"github.com/coder/coder/v2/aibridge/keypool"
|
|
aibridgemetrics "github.com/coder/coder/v2/aibridge/metrics"
|
|
agpl "github.com/coder/coder/v2/cli"
|
|
"github.com/coder/coder/v2/cli/clilog"
|
|
"github.com/coder/coder/v2/coderd/aibridged"
|
|
coderdtracing "github.com/coder/coder/v2/coderd/tracing"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/enterprise/coderd"
|
|
"github.com/coder/retry"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
const (
|
|
// The sum of daemonShutdownTimeout, httpShutdownTimeout,
|
|
// providerReloadShutdownTimeout, and traceShutdownTimeout must stay below
|
|
// terminationGracePeriodSeconds in helm/ai-gateway/values.yaml so the
|
|
// process can complete graceful shutdown before Kubernetes sends SIGKILL.
|
|
daemonShutdownTimeout = 5 * time.Second
|
|
httpShutdownTimeout = 5 * time.Minute
|
|
providerReloadShutdownTimeout = 5 * time.Second
|
|
traceShutdownTimeout = 5 * time.Second
|
|
|
|
healthzPath = "/healthz"
|
|
readyzPath = "/readyz"
|
|
|
|
keyFlagsExclusiveErr = "--key and --key-file options are mutually exclusive"
|
|
keyFlagsMissingErr = "an AI Gateway key is required, set --key (CODER_AI_GATEWAY_KEY) or --key-file (CODER_AI_GATEWAY_KEY_FILE)"
|
|
)
|
|
|
|
// aiGatewayInheritedEnvs are the coderd deployment options, keyed by env var,
|
|
// that the standalone Gateway inherits.
|
|
var aiGatewayInheritedEnvs = map[string]struct{}{
|
|
// Logging
|
|
"CODER_LOGGING_HUMAN": {},
|
|
"CODER_LOGGING_JSON": {},
|
|
"CODER_LOGGING_STACKDRIVER": {},
|
|
"CODER_LOG_FILTER": {},
|
|
"CODER_VERBOSE": {},
|
|
|
|
// Tracing
|
|
"CODER_TRACE_DATADOG": {},
|
|
"CODER_TRACE_ENABLE": {},
|
|
"CODER_TRACE_HONEYCOMB_API_KEY": {},
|
|
"CODER_TRACE_LOGS": {},
|
|
|
|
// AI Gateway
|
|
"CODER_AI_GATEWAY_ALLOW_BYOK": {},
|
|
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {},
|
|
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_FAILURE_THRESHOLD": {},
|
|
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_INTERVAL": {},
|
|
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_MAX_REQUESTS": {},
|
|
"CODER_AI_GATEWAY_CIRCUIT_BREAKER_TIMEOUT": {},
|
|
"CODER_AI_GATEWAY_DUMP_DIR": {},
|
|
"CODER_AI_GATEWAY_MAX_CONCURRENCY": {},
|
|
"CODER_AI_GATEWAY_RATE_LIMIT": {},
|
|
"CODER_AI_GATEWAY_SEND_ACTOR_HEADERS": {},
|
|
|
|
// Prometheus
|
|
"CODER_PROMETHEUS_ADDRESS": {},
|
|
"CODER_PROMETHEUS_ENABLE": {},
|
|
}
|
|
|
|
// aiGatewayStart runs the AI Gateway as a standalone process.
|
|
func (r *RootCmd) aiGatewayStart() *serpent.Command {
|
|
var (
|
|
key string
|
|
keyFile string
|
|
httpAddress string
|
|
tlsCertFile string
|
|
tlsKeyFile string
|
|
)
|
|
|
|
vals := new(codersdk.DeploymentValues)
|
|
|
|
cmd := &serpent.Command{
|
|
Use: "start",
|
|
Short: "Run a standalone AI Gateway server",
|
|
Long: "Runs a standalone replica of the AI Gateway. Standalone replicas " +
|
|
"serve LLM client traffic on a dedicated HTTP listener and connect " +
|
|
"to coderd using the Coder deployment URL and an AI Gateway key.\n\n" +
|
|
"Set --url or CODER_URL to the Coder deployment address, and set " +
|
|
"--key (CODER_AI_GATEWAY_KEY) or --key-file " +
|
|
"(CODER_AI_GATEWAY_KEY_FILE). A user login or session token is " +
|
|
"not required.",
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
signalCtx, stop := inv.SignalNotifyContext(inv.Context(), agpl.StopSignals...)
|
|
defer stop()
|
|
|
|
resolvedKey, err := resolveAIGatewayKey(key, keyFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// TLS is opt-in and requires both files; setting only one is
|
|
// an error. Default is plain HTTP.
|
|
if (tlsCertFile == "") != (tlsKeyFile == "") {
|
|
return xerrors.New("--tls-cert-file and --tls-key-file options must be provided together")
|
|
}
|
|
|
|
serverURL, transport, err := r.ResolveClientConnection()
|
|
if err != nil {
|
|
if errors.Is(err, agpl.ErrClientURLNotConfigured) {
|
|
return xerrors.New("AI Gateway requires --url or CODER_URL to point at the Coder deployment")
|
|
}
|
|
return xerrors.Errorf("configure Coder deployment connection: %w", err)
|
|
}
|
|
|
|
logger, closeLogger, err := clilog.New(clilog.FromDeploymentValues(vals)).Build(inv)
|
|
if err != nil {
|
|
return xerrors.Errorf("make logger: %w", err)
|
|
}
|
|
defer closeLogger()
|
|
|
|
logger.Debug(signalCtx, "started debug logging")
|
|
logger.Sync()
|
|
|
|
registry := prometheus.NewRegistry()
|
|
registry.MustRegister(collectors.NewGoCollector())
|
|
registry.MustRegister(collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}))
|
|
|
|
gatewayRegisterer := prometheus.WrapRegistererWithPrefix(aibridgemetrics.PrometheusMetricPrefix, registry)
|
|
metrics := aibridge.NewMetrics(gatewayRegisterer)
|
|
providerMetrics := aibridged.NewMetrics(gatewayRegisterer)
|
|
|
|
tracerProvider, _, closeTracing := agpl.ConfigureTraceProviderWithService(signalCtx, logger, vals, "coder-ai-gateway")
|
|
// The tracer is shared by the gateway's HTTP middleware, pool, and
|
|
// daemon, so it must be flushed only after runStandaloneGateway returns
|
|
// and all span producers have stopped, hence the handler-level defer.
|
|
defer func() {
|
|
logger.Debug(signalCtx, "closing tracing")
|
|
traceCloseErr := shutdownWithTimeout(closeTracing, traceShutdownTimeout)
|
|
logger.Debug(signalCtx, "tracing closed", slog.Error(traceCloseErr))
|
|
}()
|
|
tracer := tracerProvider.Tracer("ai-gateway")
|
|
|
|
if vals.Prometheus.Enable.Value() {
|
|
logger.Info(signalCtx, "starting Prometheus endpoint", slog.F("address", vals.Prometheus.Address.String()))
|
|
closeFunc := agpl.ServeHandler(signalCtx, logger, promhttp.InstrumentMetricHandler(
|
|
registry, promhttp.HandlerFor(registry, promhttp.HandlerOpts{}),
|
|
), vals.Prometheus.Address.String(), "prometheus")
|
|
defer closeFunc()
|
|
}
|
|
|
|
gatewayLogger := logger.Named("ai-gateway")
|
|
// Standalone Gateway starts with an empty pool. Providers are
|
|
// fetched later via GetAIProviders DRPC and pool is updated.
|
|
pool, err := aibridged.NewCachedBridgePool(aibridged.DefaultPoolOptions, nil, gatewayLogger.Named("pool"), metrics, tracer)
|
|
if err != nil {
|
|
return xerrors.Errorf("create request pool: %w", err)
|
|
}
|
|
gatewayRegisterer.MustRegister(keypool.NewStateCollector(pool.KeyPools))
|
|
|
|
return runStandaloneGateway(signalCtx, standaloneGatewayParams{
|
|
bridgeConfig: vals.AI.BridgeConfig,
|
|
coderURL: serverURL.String(),
|
|
httpAddress: httpAddress,
|
|
tlsCertFile: tlsCertFile,
|
|
tlsKeyFile: tlsKeyFile,
|
|
|
|
dialer: aibridged.NewWebsocketDialer(serverURL, transport, resolvedKey),
|
|
pool: pool,
|
|
|
|
logger: gatewayLogger,
|
|
metrics: metrics,
|
|
providerMetrics: providerMetrics,
|
|
tracer: tracer,
|
|
})
|
|
},
|
|
}
|
|
|
|
cmd.Options = serpent.OptionSet{
|
|
{
|
|
Flag: "key",
|
|
Env: "CODER_AI_GATEWAY_KEY",
|
|
Description: "The AI Gateway key used to authenticate to coderd.",
|
|
Value: serpent.StringOf(&key),
|
|
},
|
|
{
|
|
Flag: "key-file",
|
|
Env: "CODER_AI_GATEWAY_KEY_FILE",
|
|
Description: "Path to a file containing the AI Gateway key used to authenticate to coderd.",
|
|
Value: serpent.StringOf(&keyFile),
|
|
},
|
|
{
|
|
Flag: "http-address",
|
|
Env: "CODER_AI_GATEWAY_HTTP_ADDRESS",
|
|
Description: "The bind address to serve incoming AI Gateway client traffic.",
|
|
Default: "127.0.0.1:4001",
|
|
Value: serpent.StringOf(&httpAddress),
|
|
},
|
|
{
|
|
Flag: "tls-cert-file",
|
|
Env: "CODER_AI_GATEWAY_TLS_CERT_FILE",
|
|
Description: "Path to a PEM-encoded TLS certificate. Enables TLS termination when set together with --tls-key-file.",
|
|
Value: serpent.StringOf(&tlsCertFile),
|
|
},
|
|
{
|
|
Flag: "tls-key-file",
|
|
Env: "CODER_AI_GATEWAY_TLS_KEY_FILE",
|
|
Description: "Path to a PEM-encoded TLS private key. Enables TLS termination when set together with --tls-cert-file.",
|
|
Value: serpent.StringOf(&tlsKeyFile),
|
|
},
|
|
}
|
|
|
|
for _, opt := range vals.Options() {
|
|
if _, ok := aiGatewayInheritedEnvs[opt.Env]; ok {
|
|
cmd.Options = append(cmd.Options, opt)
|
|
}
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
type standaloneGatewayParams struct {
|
|
// Configuration.
|
|
bridgeConfig codersdk.AIBridgeConfig
|
|
coderURL string
|
|
httpAddress string
|
|
tlsCertFile string
|
|
tlsKeyFile string
|
|
|
|
// Runtime dependencies.
|
|
dialer aibridged.Dialer
|
|
pool aibridged.Pooler
|
|
|
|
// Observability.
|
|
// logger is the gateway-scoped logger; derived loggers (daemon,
|
|
// providers) are named under it.
|
|
logger slog.Logger
|
|
metrics *aibridge.Metrics
|
|
providerMetrics *aibridged.Metrics
|
|
tracer trace.Tracer
|
|
}
|
|
|
|
type standaloneGateway struct {
|
|
// Services.
|
|
daemon *aibridged.Server
|
|
httpServer *http.Server
|
|
reloader aibridged.ProviderReloader
|
|
|
|
// Configuration.
|
|
coderURL string
|
|
httpAddress string
|
|
tlsCertFile string
|
|
tlsKeyFile string
|
|
|
|
// State.
|
|
// providersLoaded is an initial-load latch. Reconnects refresh providers
|
|
// through the watch loop without resetting readiness.
|
|
providersLoaded atomic.Bool
|
|
|
|
// Observability.
|
|
logger slog.Logger
|
|
providerLogger slog.Logger
|
|
}
|
|
|
|
// runStandaloneGateway starts the aibridged daemon and serves the standalone
|
|
// AI Gateway. The daemon dials coderd asynchronously, so HTTP serving does not
|
|
// wait for the DRPC connection. It manages the daemon life cycle.
|
|
func runStandaloneGateway(ctx context.Context, params standaloneGatewayParams) error {
|
|
// The aibridged daemon must outlive ctx so in-flight HTTP requests
|
|
// retain their DRPC connection during graceful HTTP shutdown.
|
|
daemon, err := aibridged.New(context.Background(), params.pool, params.dialer, params.logger.Named("aibridged"), params.tracer)
|
|
if err != nil {
|
|
return xerrors.Errorf("start AI Gateway daemon: %w", err)
|
|
}
|
|
|
|
providerLogger := params.logger.Named("providers")
|
|
gateway := &standaloneGateway{
|
|
daemon: daemon,
|
|
reloader: agpl.NewPoolRPCReloader(params.pool, daemon.ClientContext, params.bridgeConfig, providerLogger, params.metrics, params.providerMetrics),
|
|
|
|
coderURL: params.coderURL,
|
|
httpAddress: params.httpAddress,
|
|
tlsCertFile: params.tlsCertFile,
|
|
tlsKeyFile: params.tlsKeyFile,
|
|
|
|
logger: params.logger,
|
|
providerLogger: providerLogger,
|
|
}
|
|
gateway.httpServer = &http.Server{
|
|
Handler: newGatewayMux(gateway.daemon, gateway.ready, gatewayMiddleware(params.bridgeConfig, params.tracer)),
|
|
ReadHeaderTimeout: time.Minute,
|
|
}
|
|
|
|
serveErr := gateway.serve(ctx)
|
|
var daemonShutdownErr error
|
|
if err := shutdownWithTimeout(daemon.Shutdown, daemonShutdownTimeout); err != nil {
|
|
daemonShutdownErr = xerrors.Errorf("shutdown AI Gateway daemon: %w", err)
|
|
}
|
|
return errors.Join(serveErr, daemonShutdownErr)
|
|
}
|
|
|
|
func (s *standaloneGateway) serve(ctx context.Context) error {
|
|
listener, err := net.Listen("tcp", s.httpAddress)
|
|
if err != nil {
|
|
return xerrors.Errorf("listen on %q: %w", s.httpAddress, err)
|
|
}
|
|
|
|
serveErr := make(chan error, 1)
|
|
var serveWG sync.WaitGroup
|
|
serveWG.Go(func() {
|
|
defer listener.Close()
|
|
if s.tlsCertFile != "" {
|
|
serveErr <- s.httpServer.ServeTLS(listener, s.tlsCertFile, s.tlsKeyFile)
|
|
return
|
|
}
|
|
serveErr <- s.httpServer.Serve(listener)
|
|
})
|
|
|
|
s.logger.Info(ctx, "standalone AI Gateway listening",
|
|
slog.F("address", listener.Addr().String()),
|
|
slog.F("coder_url", s.coderURL),
|
|
slog.F("tls", s.tlsCertFile != ""),
|
|
)
|
|
|
|
provReloadCtx, provReloadCancel := context.WithCancel(ctx)
|
|
provReloadDone := make(chan struct{})
|
|
go func() {
|
|
defer close(provReloadDone)
|
|
if err := s.loadProviders(provReloadCtx); err != nil {
|
|
if provReloadCtx.Err() == nil {
|
|
s.providerLogger.Error(provReloadCtx, "initial ai provider load stopped", slog.Error(err))
|
|
}
|
|
return
|
|
}
|
|
// WatchProviderReload reconnects internally and normally returns only when canceled.
|
|
err := aibridged.WatchProviderReload(provReloadCtx, s.daemon.ClientContext, s.reloader, s.providerLogger)
|
|
if err != nil && provReloadCtx.Err() == nil {
|
|
s.providerLogger.Error(provReloadCtx, "ai provider reload watch stopped", slog.Error(err))
|
|
}
|
|
}()
|
|
|
|
var runErr error
|
|
select {
|
|
case <-ctx.Done():
|
|
case <-s.daemon.Done():
|
|
// daemon uses context.Background() so no race with ctx.Done() is possible, ctx.Err() check is not needed.
|
|
runErr = xerrors.Errorf("AI Gateway daemon exited: %w", s.daemon.Err())
|
|
case <-provReloadDone:
|
|
if ctx.Err() == nil {
|
|
select {
|
|
// reload can exit due to daemon failure
|
|
// covering race with previous daemon.Done() case.
|
|
case <-s.daemon.Done():
|
|
runErr = xerrors.Errorf("AI Gateway daemon exited: %w", s.daemon.Err())
|
|
default:
|
|
runErr = xerrors.New("provider reload stopped unexpectedly")
|
|
}
|
|
}
|
|
case err := <-serveErr:
|
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
runErr = xerrors.Errorf("serve: %w", err)
|
|
}
|
|
}
|
|
s.logger.Info(ctx, "shutting down standalone AI Gateway")
|
|
|
|
provReloadCancel()
|
|
provReloadShutdownCtx, provReloadShutdownCancel := context.WithTimeout(context.Background(), providerReloadShutdownTimeout)
|
|
defer provReloadShutdownCancel()
|
|
|
|
var provReloadStopErr error
|
|
select {
|
|
case <-provReloadDone:
|
|
case <-provReloadShutdownCtx.Done():
|
|
provReloadStopErr = xerrors.Errorf("provider reload did not stop within %s, continuing gateway shutdown", providerReloadShutdownTimeout)
|
|
}
|
|
|
|
// Provider reload normally stops before HTTP draining so it cannot clear the
|
|
// bridge cache while requests are draining. If it does not stop within its
|
|
// timeout, continue with best-effort graceful HTTP shutdown.
|
|
// The daemon remains connected so in-flight requests retain their DRPC connection.
|
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), httpShutdownTimeout)
|
|
defer shutdownCancel()
|
|
var httpShutdownErr error
|
|
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
|
|
httpShutdownErr = xerrors.Errorf("shutdown http server: %w", err)
|
|
if closeErr := s.httpServer.Close(); closeErr != nil {
|
|
httpShutdownErr = errors.Join(httpShutdownErr, xerrors.Errorf("force close http server: %w", closeErr))
|
|
}
|
|
}
|
|
serveWG.Wait()
|
|
return errors.Join(runErr, provReloadStopErr, httpShutdownErr)
|
|
}
|
|
|
|
// loadProviders retries the initial provider load until it succeeds or the
|
|
// context or daemon stops. A successful empty provider list completes the
|
|
// initial load. Subsequent changes are handled by the watch loop.
|
|
func (s *standaloneGateway) loadProviders(ctx context.Context) error {
|
|
for r := retry.New(50*time.Millisecond, 10*time.Second); r.Wait(ctx); {
|
|
if err := s.reloader.Reload(ctx); err != nil {
|
|
select {
|
|
case <-s.daemon.Done():
|
|
return err
|
|
default:
|
|
}
|
|
s.providerLogger.Warn(ctx, "failed to load ai providers, will retry", slog.Error(err))
|
|
continue
|
|
}
|
|
s.providersLoaded.Store(true)
|
|
s.providerLogger.Info(ctx, "loaded ai providers from coderd")
|
|
return nil
|
|
}
|
|
return context.Cause(ctx)
|
|
}
|
|
|
|
func (s *standaloneGateway) ready() bool {
|
|
return s.daemon.Ready() && s.providersLoaded.Load()
|
|
}
|
|
|
|
func gatewayMiddleware(cfg codersdk.AIBridgeConfig, tracer trace.Tracer) func(http.Handler) http.Handler {
|
|
mw := coderd.AIGatewayDataPlaneMiddleware(cfg)
|
|
// Tracing wraps outermost so rejected requests are still traced.
|
|
traced := tracingMiddleware(tracer)
|
|
return func(next http.Handler) http.Handler {
|
|
return traced(mw(next))
|
|
}
|
|
}
|
|
|
|
// tracingMiddleware traces every request to the wrapped handler, unlike
|
|
// tracing.Middleware which only spans coderd's route patterns.
|
|
func tracingMiddleware(tracer trace.Tracer) func(http.Handler) http.Handler {
|
|
return func(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
sw := &coderdtracing.StatusWriter{ResponseWriter: rw}
|
|
r, span := coderdtracing.StartHTTPSpan(tracer, sw, r, fmt.Sprintf("%s %s", r.Method, r.URL.Path))
|
|
defer span.End()
|
|
|
|
next.ServeHTTP(sw, r)
|
|
|
|
status := sw.Status
|
|
if status == 0 {
|
|
status = http.StatusOK
|
|
}
|
|
span.SetAttributes(
|
|
semconv.HTTPMethodKey.String(r.Method),
|
|
semconv.HTTPTargetKey.String(r.URL.RequestURI()),
|
|
semconv.HTTPStatusCodeKey.Int(status),
|
|
)
|
|
span.SetStatus(httpconv.ServerStatus(status))
|
|
})
|
|
}
|
|
}
|
|
|
|
func newGatewayMux(aibridgedHandler http.Handler, aibridgedReady func() bool, middleware func(http.Handler) http.Handler) *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("/api/v2/aibridge/", middleware(http.StripPrefix("/api/v2/aibridge", aibridgedHandler)))
|
|
mux.Handle("/api/v2/ai-gateway/", middleware(http.StripPrefix("/api/v2/ai-gateway", aibridgedHandler)))
|
|
mux.Handle("/", middleware(aibridgedHandler))
|
|
|
|
// Health probes are registered without middleware.
|
|
mux.HandleFunc(healthzPath, func(w http.ResponseWriter, _ *http.Request) {
|
|
// healthz: returns 200 once the HTTP server is listening.
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
|
|
mux.HandleFunc(readyzPath, func(w http.ResponseWriter, _ *http.Request) {
|
|
// readyz: returns 200 after the initial provider load while the
|
|
// DRPC connection to coderd remains active.
|
|
if aibridgedReady() {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
})
|
|
|
|
return mux
|
|
}
|
|
|
|
// resolveAIGatewayKey resolves key from --key or --key-file flags.
|
|
// If both are set, an error is returned. If neither is set, an empty string is returned.
|
|
func resolveAIGatewayKey(key string, keyFile string) (string, error) {
|
|
if key != "" && keyFile != "" {
|
|
return "", xerrors.New(keyFlagsExclusiveErr)
|
|
}
|
|
if key == "" && keyFile == "" {
|
|
return "", xerrors.New(keyFlagsMissingErr)
|
|
}
|
|
if keyFile == "" {
|
|
return key, nil
|
|
}
|
|
data, err := os.ReadFile(keyFile)
|
|
if err != nil {
|
|
return "", xerrors.Errorf("read AI Gateway key file %q: %w", keyFile, err)
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|