[amazon-cloudwatch-agent-operator] Route SM/PM by cloudwatch.aws/scraper annotation - #399
[amazon-cloudwatch-agent-operator] Route SM/PM by cloudwatch.aws/scraper annotation#399wenegiemepraise wants to merge 12 commits into
Conversation
…er startup The target-allocator declared the enable-prometheus-cr-watcher flag name as a constant but never registered it on the flag set, while the operator passes --enable-prometheus-cr-watcher whenever PrometheusCR.enabled is true. Because args are parsed with pflag.ExitOnError, the unregistered flag caused the binary to print 'unknown flag' and exit(2), putting the target-allocator pod into CrashLoopBackOff. This change registers the flag and ORs it with the YAML prometheus_cr.enabled setting, then fixes three latent defects that were previously unreachable because the binary crashed first: - promOperator: set a non-empty Namespace on the synthetic Prometheus object so the prometheus-operator config generator no longer panics with 'namespace can't be empty' in store.ForNamespace. - promOperator: set EvaluationInterval so the generated config does not render an empty global.evaluation_interval, which the prometheus config parser rejects with 'empty duration string'. - main: create and register service-discovery metrics and pass them to discovery.NewManager; passing a nil sdMetrics map makes every SD provider fail to register, yielding zero discovered targets. RELEASE_NOTES updated.
Add a regression test asserting that loading a Target Allocator config whose static scrape job omits scrape_protocols still yields a non-empty ScrapeProtocols on every loaded scrape config. This is defaulted by the pinned Prometheus library during yaml.UnmarshalStrict into the prometheus Config type, so the distributed /scrape_configs payload is never empty and the agent's prometheus-receiver validation passes. The test fails fast if a future dependency or load-path change drops this defaulting.
The pod-template restart-trigger sha256 was computed from Spec.Config only, so a change to Spec.Prometheus (rendered into a separate ConfigMap) left the pod template byte-identical and the workload controller did not roll the pods. Fold the serialized Spec.Prometheus (PrometheusConfig.Yaml()) into the hash input when it is non-empty, so a Prometheus-only change bumps the pod-template annotation and triggers a rolling restart, matching agent-config behavior. When no Prometheus config is set the hash input is byte-identical to the agent config alone, leaving non-Prometheus agents unaffected.
Add a per-node allocation strategy so each CloudWatch agent (DaemonSet, one per node) scrapes only the ServiceMonitor/PodMonitor targets on its own node, eliminating cross-node/cross-AZ scrape traffic. Targets that cannot be matched to a node-local agent (node-less endpoints, nodes without a Ready agent) fall back to consistent-hashing so they are never silently dropped. - allocation/per_node.go: perNodeAllocator (node index, consistent-hashing fallback ring, unassigned tracking, descriptive logging) + registration. - allocation/strategy.go: Collector.NodeName, NewCollector(name, node), WithFallbackStrategy option, targets_unassigned gauge. - collector/collector.go: capture pod.Spec.NodeName, skip empty-NodeName pods, handle watch.Modified so a collector's node is picked up once scheduled (fixes targets being stuck on the fallback after a DaemonSet rollout). - target/target.go: GetNodeName() from __meta_kubernetes_*_node_name labels. - config + main: FallbackAllocationStrategy wiring. - apis/v1alpha1 + CRD: allocationStrategy enum gains "per-node". - internal/manifests/targetallocator/configmap.go: emit per-node strategy and the consistent-hashing fallback from the CR.
# Conflicts: # cmd/amazon-cloudwatch-agent-target-allocator/main.go
Route ServiceMonitor/PodMonitor discovery across CloudWatch agents by the cloudwatch.aws/scraper annotation on the monitor CR. A monitor annotated cloudwatch.aws/scraper: cluster-scraper is scraped only by the cluster-scraper agent's Target Allocator; all others only by the per-node agent's. Add a scraperRole field to the Target Allocator prometheusCR config (spec.targetAllocator.prometheusCR.scraperRole) and a client-side annotation filter in the TA watcher (annotationRoleMatches) applied during monitor discovery. The cluster-scraper role keeps only annotated monitors; the default (empty) role keeps only unannotated ones, so the two roles partition monitors with no overlap and no gap.
Add a LoadConfig-level test proving the scraper annotation filter is applied during monitor discovery (cluster-scraper and default roles keep/skip the right monitors), and a ConfigMap test asserting scraper_role is emitted when set on the prometheusCR.
Refactor the annotation filter into PrometheusCRWatcher.selectsMonitor and log each ServiceMonitor/PodMonitor the cluster-scraper agent claims via the cloudwatch.aws/scraper annotation (the override event). Add unit tests bringing the routing logic to 100% statement coverage: annotationRoleMatches and selectsMonitor (incl. asserting the override log fires only for the cluster-scraper role), a LoadConfig-level routing test, and a configmap scraper_role assertion.
| // not so annotated. This lets a heavy/singleton monitor be routed to the central cluster-scraper | ||
| // agent while all others stay on the per-node agent. | ||
| // +optional | ||
| ScraperRole string `json:"scraperRole,omitempty"` |
There was a problem hiding this comment.
scraperRole has no enum validation, so a typo like cluster-scaper quietly falls back to the default role and that monitor gets scraped by nobody. The chart hardcodes the right value today, but could we add +kubebuilder:validation:Enum=cluster-scraper and regenerate the CRD?
| if !annotationRoleMatches(w.scraperRole, annotations) { | ||
| return false | ||
| } | ||
| if w.scraperRole == clusterScraperRole { |
There was a problem hiding this comment.
selectsMonitor logs an Info line for every monitor the cluster-scraper claims, and LoadConfig reruns on each reconcile, so a big annotated set means a lot of repeated lines. Low risk, but could we log only on membership change or drop it to V(1)?
| // TestLoadConfigScraperRouting exercises the annotation filter through the real LoadConfig path | ||
| // (matching TestLoadConfig's harness): an annotated ServiceMonitor is discovered by the | ||
| // cluster-scraper role and excluded by the default role. | ||
| func TestLoadConfigScraperRouting(t *testing.T) { |
There was a problem hiding this comment.
The unannotated and PodMonitor cases never actually go through LoadConfig even though PodMonitor discovery mirrors ServiceMonitor, and the informer wait is a HasSynced spin loop with no deadline that hangs until CI kills it. Could we add both LoadConfig cases and use cache.WaitForCacheSync with a timeout?
| // Allocator, based on its scraperRole. cluster-scraper role selects only monitors annotated | ||
| // cloudwatch.aws/scraper: cluster-scraper; the default role (empty) selects only monitors that are | ||
| // not so annotated, so the two roles partition monitors with no overlap and no gap. | ||
| func annotationRoleMatches(scraperRole string, annotations map[string]string) bool { |
There was a problem hiding this comment.
Routing is binary here (only cluster-scraper routes, everything else falls to per-node), so a future gpu-scraper would silently land on per-node. Could we add a note that annotationRoleMatches needs to become an explicit role to value match before a third role shows up?
| } | ||
|
|
||
| if len(params.OtelCol.Spec.TargetAllocator.PrometheusCR.ScraperRole) > 0 { | ||
| taConfig["scraper_role"] = params.OtelCol.Spec.TargetAllocator.PrometheusCR.ScraperRole |
There was a problem hiding this comment.
The operator writes the scraper_role key, which an older Target Allocator image rejects as unknown and then CrashLoopBackOffs on, taking the cluster-scraper path down. Could we make sure the chart pins the operator and Target Allocator images from this commit or newer together?
scraperRole had no validation, so a typo (e.g. cluster-scaper) silently fell back to the default role and that monitor was scraped by nobody. Add +kubebuilder:validation:Enum=cluster-scraper and the matching CRD enum so the API server rejects invalid values. The field stays optional/omitempty, so the default per-node agents (no scraperRole) are unaffected.
selectsMonitor logged an Info line for every monitor the cluster-scraper claims, and LoadConfig reruns each reconcile, so a large annotated set produced repeated log spam. Drop it to V(1) (debug). Bump the test's funcr verbosity to 1 so it still asserts the routing log.
…adConfig TestLoadConfigScraperRouting only exercised an annotated ServiceMonitor and waited on an unbounded HasSynced spin loop. Cover both kinds (PodMonitor mirrors ServiceMonitor) and both annotated/unannotated for each role through LoadConfig, and replace the spin loop with cache.WaitForCacheSync bounded by a timeout so a stuck informer fails fast.
annotationRoleMatches routes only cluster-scraper vs everything-else, so a future role (e.g. gpu-scraper) would silently fall through to the per-node bucket. Document that it must become an explicit role-to-annotation-value match before a third role is added.
Summary
Route ServiceMonitor/PodMonitor discovery across CloudWatch agents by the
cloudwatch.aws/scraperannotation on the monitor CR. A monitor annotatedcloudwatch.aws/scraper: cluster-scraperis scraped only by the cluster-scraper agent's TargetAllocator; all others only by the per-node agent's.
What
spec.targetAllocator.prometheusCR.scraperRolefield (Target Allocator configscraper_role).annotationRoleMatches) applied during monitordiscovery in
LoadConfig.cluster-scraperrole keeps only annotated monitors; the empty(default) role keeps only unannotated ones — complementary, so every monitor is owned by exactly
one agent (no double-scrape, no gap).
Why annotation (not label)
The routing key is behavioural config, not identity. Both agents' TAs already list all monitors
into their cache and filter client-side, and the two roles' selections union to the full set — so
server-side label filtering would gain nothing here. Filtering by annotation is a few lines in the
existing
ListAllcallback.Testing
go build ./...and unit tests pass, incl. newTestAnnotationRoleMatches(8 cases + partition invariant).cluster-scraper TA
/jobs; an unannotated one only in the per-node TA/jobs.GOPROXY=direct GOSUMDB=off(offline proxy); CI is the authoritative gate.Dependencies / stacking
Stacks on the per-node allocation operator work (#398,
ta-per-node-allocation). Companion helm PR:[amazon-cloudwatch-observability] Route SM/PM to cluster-scraper by annotation.