mirror of
https://github.com/agent-substrate/substrate.git
synced 2026-11-11 10:23:17 +08:00
Fixes #1877 `newMeterProvider` always installed the OTLP periodic reader, so a deployment that scrapes the components' Prometheus endpoints could not stop the push and received every instrument twice. `OTEL_METRICS_EXPORTER` now accepts `otlp` (the default) and `none`, and warns on any other value and keeps OTLP, like `OTEL_LOGS_EXPORTER`. With `none` the OTLP reader is skipped and the Prometheus reader stays. atecontroller moves to a new `serverboot.InitMetricsBridged`: with OTLP it bridges controller-runtime's registry onto the push and keeps the OTel instruments off that registry; with `none` it registers `ate.workerpool.*` on that registry so the manager's `:8080` serves them. Its optional producer wrapper applies on the OTLP path only, where atecontroller keeps padding empty exponential histograms for the Telemetry API. ateom is unchanged apart from honoring the variable. Documented in `docs/observability.md` ("Scraping instead of pushing"). - [x] Tests pass (`internal/serverboot`: value resolution and the warning; `InitMetrics` dials by default and not with `none`; `InitMetricsBridged` pushes the bridged family and the OTel instrument exactly once to a fake OTLP collector without registering the instrument on the bridged registry, collects through the producer wrapper, and with `none` serves both from that registry without dialing) - [x] Appropriate changes to documentation are included in the PR
267 lines
11 KiB
Go
267 lines
11 KiB
Go
// Copyright 2026 Google LLC
|
|
//
|
|
// 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 main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
|
|
"github.com/agent-substrate/substrate/cmd/atecontroller/internal/controllers"
|
|
"github.com/agent-substrate/substrate/cmd/atecontroller/internal/workersync"
|
|
"github.com/agent-substrate/substrate/internal/ateapiauth"
|
|
"github.com/agent-substrate/substrate/internal/installdefaults"
|
|
"github.com/agent-substrate/substrate/internal/serverboot"
|
|
"github.com/agent-substrate/substrate/internal/version"
|
|
clientv1alpha1 "github.com/agent-substrate/substrate/pkg/api/v1alpha1"
|
|
"github.com/agent-substrate/substrate/pkg/client/clientset/versioned"
|
|
"github.com/agent-substrate/substrate/pkg/client/informers/externalversions"
|
|
"github.com/agent-substrate/substrate/pkg/proto/ateapipb"
|
|
"github.com/go-logr/logr"
|
|
"github.com/spf13/pflag"
|
|
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
|
|
"google.golang.org/grpc"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/fields"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
|
"k8s.io/client-go/kubernetes"
|
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/cache"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/healthz"
|
|
ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
|
|
|
|
// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
|
|
_ "k8s.io/client-go/plugin/pkg/client/auth"
|
|
)
|
|
|
|
var (
|
|
scheme = runtime.NewScheme()
|
|
setupLog = ctrl.Log.WithName("setup")
|
|
|
|
ateAPIConnSpec = pflag.String("ateapi-conn-spec", "k8s:///api.ate-system.svc:443", "")
|
|
|
|
showVersion = pflag.Bool("version", false, "Print version and exit.")
|
|
logLevelFlag = pflag.String("log-level", "info", "Minimum log level: debug, info, warn, or error.")
|
|
|
|
otelEndpoint = pflag.String("otel-exporter-otlp-endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"),
|
|
"OTLP endpoint set on ateom worker pods so they push telemetry. Defaults to the controller's own OTEL_EXPORTER_OTLP_ENDPOINT.")
|
|
|
|
otelMetricExportInterval = pflag.String("otel-metric-export-interval", os.Getenv("OTEL_METRIC_EXPORT_INTERVAL"),
|
|
"Metric export interval in milliseconds set on ateom worker pods. Empty keeps the OTel SDK's 60s default. Defaults to the controller's own OTEL_METRIC_EXPORT_INTERVAL.")
|
|
|
|
otelMetricExportTimeout = pflag.String("otel-metric-export-timeout", os.Getenv("OTEL_METRIC_EXPORT_TIMEOUT"),
|
|
"Per-export timeout in milliseconds set on ateom worker pods. Empty keeps the OTel SDK's 30s default. Defaults to the controller's own OTEL_METRIC_EXPORT_TIMEOUT.")
|
|
|
|
otelTracesSampler = pflag.String("otel-traces-sampler", os.Getenv("OTEL_TRACES_SAMPLER"),
|
|
"Trace sampler set on ateom worker pods. Empty keeps the ateom binary's default. Defaults to the controller's own OTEL_TRACES_SAMPLER.")
|
|
|
|
otelTracesSamplerArg = pflag.String("otel-traces-sampler-arg", os.Getenv("OTEL_TRACES_SAMPLER_ARG"),
|
|
"Trace sampler argument set on ateom worker pods, ignored unless --otel-traces-sampler is set. Defaults to the controller's own OTEL_TRACES_SAMPLER_ARG.")
|
|
|
|
ateletServiceAccount = pflag.String("atelet-service-account", installdefaults.AteletServiceAccount, "ServiceAccount atelet runs as. It is the service-account segment of the SPIFFE ID each worker's atunnel expects on the credential broker, so it has to match what the deployment actually creates.")
|
|
routerServiceAccount = pflag.String("router-service-account", installdefaults.RouterServiceAccount, "ServiceAccount atenet-router runs as. It is the service-account segment of the SPIFFE ID each worker's atunnel accepts on actor ingress, so it has to match what the deployment actually creates.")
|
|
|
|
ateapiCAFile = pflag.String("ateapi-ca-file", ateapiauth.DefaultServiceAccountCAFile, "PEM file with CAs trusted to verify the ateapi server cert.")
|
|
ateapiServerName = pflag.String("ateapi-server-name", "", "SNI / hostname expected on the ateapi server cert. Optional.")
|
|
ateapiClientCert = pflag.String("ateapi-client-cert", "", "Credential bundle presented as the client certificate when dialing ateapi. Required.")
|
|
)
|
|
|
|
func init() {
|
|
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
|
utilruntime.Must(clientv1alpha1.AddToScheme(scheme)) // Register our CRD
|
|
}
|
|
|
|
const serviceName = "atecontroller"
|
|
|
|
// logr verbosity V(n) maps to slog level -n, so V(1) stays below Info until
|
|
// --log-level=debug. logr carries no context, so these records have no trace IDs.
|
|
func newControllerRuntimeLogger(h slog.Handler) logr.Logger {
|
|
return logr.FromSlogHandler(h)
|
|
}
|
|
|
|
func main() {
|
|
pflag.Parse()
|
|
if *showVersion {
|
|
fmt.Println(version.String())
|
|
return
|
|
}
|
|
ctx := context.Background()
|
|
serverboot.InitLogger()
|
|
if err := serverboot.SetLogLevel(*logLevelFlag); err != nil {
|
|
serverboot.Fatal(ctx, "Invalid --log-level", err)
|
|
}
|
|
slog.InfoContext(ctx, "atecontroller starting", slog.String("version", version.Version))
|
|
ctrl.SetLogger(newControllerRuntimeLogger(slog.Default().Handler()))
|
|
|
|
// Both providers must be registered before the ateapi client below:
|
|
// otelgrpc.NewClientHandler captures the global tracer and meter providers at
|
|
// construction, so a later init leaves it bound to the no-op ones.
|
|
tp, err := serverboot.InitTracing(ctx, serverboot.TracingOptions{
|
|
ServiceName: serviceName,
|
|
Sampling: serverboot.ResolveTraceSampling(ctx, serverboot.ParentRatioSampling(serverboot.ControlPlaneTraceRatio)),
|
|
})
|
|
if err != nil {
|
|
serverboot.Fatal(ctx, "Failed to initialize tracing", err)
|
|
}
|
|
defer serverboot.ShutdownProvider("TracerProvider", tp.Shutdown)
|
|
|
|
// controller-runtime records reconcile, workqueue, and runtime metrics into its
|
|
// own Prometheus registry, which the manager serves. On the OTLP path the
|
|
// bridged queue histograms are padded so the Telemetry API accepts idle ones.
|
|
mp, err := serverboot.InitMetricsBridged(ctx, serviceName, ctrlmetrics.Registry, padEmptyExponentialHistograms)
|
|
if err != nil {
|
|
serverboot.Fatal(ctx, "Failed to initialize metrics", err)
|
|
}
|
|
defer serverboot.ShutdownProvider("MeterProvider", mp.Shutdown)
|
|
|
|
k8sConfig := ctrl.GetConfigOrDie()
|
|
k8sClient, err := kubernetes.NewForConfig(k8sConfig)
|
|
if err != nil {
|
|
setupLog.Error(err, "creating kubernetes client for ateapi dialer")
|
|
os.Exit(1)
|
|
}
|
|
ateClient, err := versioned.NewForConfig(k8sConfig)
|
|
if err != nil {
|
|
setupLog.Error(err, "creating ate clientset for the worker syncer")
|
|
os.Exit(1)
|
|
}
|
|
|
|
dialOpts, err := ateapiauth.DialOptions(ateapiauth.ClientConfig{
|
|
K8sClient: k8sClient,
|
|
CAFile: *ateapiCAFile,
|
|
ServerName: *ateapiServerName,
|
|
ClientCredBundle: *ateapiClientCert,
|
|
})
|
|
if err != nil {
|
|
setupLog.Error(err, "building ateapi dial options")
|
|
os.Exit(1)
|
|
}
|
|
dialOpts = append(dialOpts, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
|
|
|
|
ateapiConn, err := grpc.NewClient(*ateAPIConnSpec, dialOpts...)
|
|
if err != nil {
|
|
setupLog.Error(err, "Error creating grpc connection to ate api")
|
|
os.Exit(1)
|
|
}
|
|
|
|
ateapiClient := ateapipb.NewControlClient(ateapiConn)
|
|
|
|
// Empty ServiceAccounts would not fail here: path.Join drops the empty
|
|
// segment, yielding identities that parse but match nothing, so every
|
|
// worker would reject both of its peers with no hint at the cause.
|
|
for flag, value := range map[string]string{"--atelet-service-account": *ateletServiceAccount, "--router-service-account": *routerServiceAccount} {
|
|
if value == "" {
|
|
setupLog.Error(nil, "invalid flag", "flag", flag, "reason", "must not be empty")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// EgressMITMTrustReconciler watches the Secret `egress-mitm-ca-pool`.
|
|
systemNamespace := installdefaults.NamespaceFromPodEnv()
|
|
egressMITMCAPool := controllers.EgressMITMCAPoolRef(systemNamespace)
|
|
mgr, err := ctrl.NewManager(k8sConfig, ctrl.Options{
|
|
Scheme: scheme,
|
|
Cache: cache.Options{
|
|
ByObject: map[client.Object]cache.ByObject{
|
|
&corev1.Secret{}: {
|
|
Namespaces: map[string]cache.Config{
|
|
egressMITMCAPool.Namespace: {
|
|
FieldSelector: fields.OneTermEqualSelector("metadata.name", egressMITMCAPool.Name),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
setupLog.Error(err, "unable to start manager")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err = (&controllers.WorkerPoolReconciler{
|
|
Client: mgr.GetClient(),
|
|
Scheme: mgr.GetScheme(),
|
|
OTelEndpoint: *otelEndpoint,
|
|
OTelMetricExportInterval: *otelMetricExportInterval,
|
|
OTelMetricExportTimeout: *otelMetricExportTimeout,
|
|
OTelTracesSampler: *otelTracesSampler,
|
|
OTelTracesSamplerArg: *otelTracesSamplerArg,
|
|
SystemNamespace: systemNamespace,
|
|
AteletServiceAccount: *ateletServiceAccount,
|
|
RouterServiceAccount: *routerServiceAccount,
|
|
}).SetupWithManager(mgr); err != nil {
|
|
setupLog.Error(err, "unable to create controller", "controller", "WorkerPool")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err = (&controllers.NetworkPolicyReconciler{
|
|
Client: mgr.GetClient(),
|
|
Scheme: mgr.GetScheme(),
|
|
SystemNamespace: systemNamespace,
|
|
}).SetupWithManager(mgr); err != nil {
|
|
setupLog.Error(err, "unable to create controller", "controller", "NetPolicy")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err = (&controllers.EgressMITMTrustReconciler{
|
|
Client: mgr.GetClient(),
|
|
SystemNamespace: systemNamespace,
|
|
}).SetupWithManager(mgr); err != nil {
|
|
setupLog.Error(err, "unable to create controller", "controller", "EgressMITMTrust")
|
|
os.Exit(1)
|
|
}
|
|
|
|
//+kubebuilder:scaffold:builder
|
|
|
|
if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
|
|
setupLog.Error(err, "unable to set up health check")
|
|
os.Exit(1)
|
|
}
|
|
if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil {
|
|
setupLog.Error(err, "unable to set up ready check")
|
|
os.Exit(1)
|
|
}
|
|
|
|
runCtx := ctrl.SetupSignalHandler()
|
|
|
|
// The worker syncer runs on informers of its own rather than the manager's
|
|
// shared cache. It needs a resync to sweep for registry records that drifted
|
|
// without a pod event, and an informer's resync period is a property of the
|
|
// informer, so asking the shared cache for one would impose it on every other
|
|
// controller here too.
|
|
ateFactory := externalversions.NewSharedInformerFactory(ateClient, 0)
|
|
workerPoolInformer := ateFactory.Api().V1alpha1().WorkerPools()
|
|
workerPodInformerFactory, workerPodInformer := workersync.WorkerPodInformer(k8sClient)
|
|
|
|
// Start registers the informer event handlers, so it has to run before the
|
|
// factory does: the initial list then synthesizes an Add for every pod that
|
|
// already exists, and no explicit startup re-list is needed.
|
|
workersync.NewWorkerPoolSyncer(ateapiClient, k8sClient.CoreV1(), workerPodInformer, workerPoolInformer.Informer()).Start(runCtx)
|
|
|
|
workerPodInformerFactory.Start(runCtx.Done())
|
|
ateFactory.Start(runCtx.Done())
|
|
workerPodInformerFactory.WaitForCacheSync(runCtx.Done())
|
|
ateFactory.WaitForCacheSync(runCtx.Done())
|
|
|
|
setupLog.Info("starting manager")
|
|
if err := mgr.Start(runCtx); err != nil {
|
|
setupLog.Error(err, "problem running manager")
|
|
os.Exit(1)
|
|
}
|
|
}
|