Skip to content

Commit 968ef25

Browse files
committed
feat(site-health-probe): synthetic monitoring component for NICo APIs
Adds nico-site-health-probe (#5360, sub-issue of #3723): a Go service that runs configurable probes against NICo APIs concurrently and exposes latency/outcome metrics for the standard collector. Architecture (dev/k8s/site-health-probe, Go 1.26.4 / toolchain 1.26.7): - Channel pipeline: one goroutine per probe (per-probe configurable interval/timeout, immediate first fire, panic recovery) produces Result events on a buffered channel; a Collector consumes them into a Sink. Probes are pure measurement (return Observations, never touch metrics), so future consumers — site-stats aggregation for host ingestion, instance creation, firmware updates (see TODOs) — attach at the Sink seam. - Per-API packages: internal/probes/nicoapi (gRPC + SPIFFE mTLS machinery) and internal/probes/restapi (HTTP + Keycloak client-credentials) are distinct; adding a probe to one API never touches the other. - v1 probes: gRPC machines (FindMachineIds + first-page FindMachinesByIds, rotation-safe cert reload), REST machines/instances (cached token, secrets re-read from mounted files, never logged). Every probe toggles independently (nico-api without REST and vice versa). - Go stubs generated from the canonical crates/rpc/proto definitions (committed; Makefile proto-gen disambiguates two oneof-vs-enum-value name collisions Go cannot compile). - Metrics: carbide_site_health_probe_{request_duration_milliseconds, requests_total,up} labeled by api (nico-api | nico-rest-api), probe, operation/outcome. Security/hardening (audited: gosec 0, govulncheck clean, tests -race): - Toolchain pinned to 1.26.7 (1.26.4's stdlib has seven reachable CVEs; the Grype image-scan gate reads the Go version from the binary). - REST client refuses redirects (Go replays POST bodies on 307/308 to any host; the token body carries the Keycloak client_secret). - Full TLS verification everywhere (no insecure mode exists); metrics server fully timeout-bounded; distroless nonroot image, digest-pinned. Core RBAC: new SiteHealthProbe principal (nico-site-health-probe) granted FindMachineIds + FindMachinesByIds only. Helm: nico-site-health-probe subchart under nico-machine-a-tron (enabled by default with the MAT chart; REST probes off until site inputs exist). The SPIFFE Certificate and its volume render only when the gRPC probe is enabled, so REST-only deployments don't depend on the core cert issuer. Hardened pod (nonroot, read-only rootfs, no capabilities, seccomp RuntimeDefault), metrics Service + gated ServiceMonitor, helm-unittest coverage incl. per-probe toggle combinations. CI: build-site-health-probe image job (mat-k8s-controller pattern), wired into the aggregate jobs. Docs: metric rows in core_metrics.md; MAT README section incl. the stale-TLS-secret reinstall note. TODO(#5360-followup): active lifecycle probes (machine_count: 1=canary, all=scale test) and progress p50/p95/p99 reporting. Signed-off-by: Milad Noori <mnoori@nvidia.com>
1 parent e3c10af commit 968ef25

45 files changed

Lines changed: 121959 additions & 2 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ jobs:
207207
- 'rust-toolchain.toml'
208208
- '.github/workflows/ci.yaml'
209209
- 'dev/k8s/machine-a-tron-controller/**'
210+
- 'dev/k8s/site-health-probe/**'
210211
- name: Calculate version
211212
id: version
212213
run: |
@@ -900,6 +901,60 @@ jobs:
900901
timeout_minutes: 30
901902
secrets: inherit
902903

904+
build-site-health-probe:
905+
if: >-
906+
${{
907+
!cancelled()
908+
&& github.event_name != 'schedule'
909+
&& needs.prepare.result == 'success'
910+
&& needs.prepare.outputs.source_files_changed == 'true'
911+
}}
912+
needs:
913+
- prepare
914+
uses: ./.github/workflows/docker-build.yml
915+
with:
916+
dockerfile_path: dev/k8s/site-health-probe/Dockerfile
917+
context_path: dev/k8s/site-health-probe
918+
image_name: ${{ needs.prepare.outputs.image_registry }}/site-health-probe
919+
image_tag: ${{ needs.prepare.outputs.version }}
920+
additional_tags: ${{ needs.prepare.outputs.image_registry }}/site-health-probe:${{ needs.prepare.outputs.major_minor_version }}-latest
921+
platforms: linux/amd64,linux/arm64
922+
runner: linux-amd64-cpu4
923+
push: ${{ needs.prepare.outputs.publish_images == 'true' }}
924+
load: false
925+
scan: true
926+
tag_latest: false
927+
timeout_minutes: 30
928+
secrets: inherit
929+
930+
test-site-health-probe:
931+
name: Test Site Health Probe
932+
if: >-
933+
${{
934+
!cancelled()
935+
&& github.event_name != 'schedule'
936+
&& needs.prepare.result == 'success'
937+
&& needs.prepare.outputs.source_files_changed == 'true'
938+
}}
939+
needs:
940+
- prepare
941+
runs-on: linux-amd64-cpu4
942+
steps:
943+
- name: Checkout code
944+
uses: actions/checkout@v4
945+
with:
946+
persist-credentials: false
947+
948+
- name: Set up Go
949+
uses: actions/setup-go@v5
950+
with:
951+
go-version-file: dev/k8s/site-health-probe/go.mod
952+
cache: true
953+
cache-dependency-path: dev/k8s/site-health-probe/go.sum
954+
955+
- name: Run tests
956+
run: make -C dev/k8s/site-health-probe vet test
957+
903958
# Like the release-container builds above, deliberately not gated on
904959
# `check-rest-core-proto-sync`: the tests do not read anything it produces,
905960
# and `core-ci-pass` still fails the merge when the protos are out of sync.
@@ -1725,6 +1780,7 @@ jobs:
17251780
helm/charts/nico-hardware-health
17261781
helm/charts/nico-machine-a-tron
17271782
helm/charts/nico-machine-a-tron/charts/mat-k8s-controller
1783+
helm/charts/nico-machine-a-tron/charts/nico-site-health-probe
17281784
helm/charts/nico-pxe
17291785
helm/charts/nico-ssh-console-rs
17301786
helm/charts/unbound
@@ -2098,6 +2154,8 @@ jobs:
20982154
- build-release-container-aarch64
20992155
- build-machine-a-tron
21002156
- build-mat-k8s-controller
2157+
- build-site-health-probe
2158+
- test-site-health-probe
21012159
- test-release-container-services
21022160
- build-boot-artifacts-x86
21032161
- build-boot-artifacts-bfb
@@ -2161,6 +2219,7 @@ jobs:
21612219
IMAGES["build-release-artifacts-x86-host"]="${{ needs.build-release-artifacts-x86-host.outputs.image_ref }}"
21622220
IMAGES["build-release-artifacts-arm-host"]="${{ needs.build-release-artifacts-arm-host.outputs.image_ref }}"
21632221
IMAGES["build-mat-k8s-controller"]="${{ needs.build-mat-k8s-controller.outputs.image_ref }}"
2222+
IMAGES["build-site-health-probe"]="${{ needs.build-site-health-probe.outputs.image_ref }}"
21642223
21652224
for job in "${!IMAGES[@]}"; do
21662225
image="${IMAGES[$job]}"
@@ -2205,6 +2264,8 @@ jobs:
22052264
- build-release-container-aarch64
22062265
- build-machine-a-tron
22072266
- build-mat-k8s-controller
2267+
- build-site-health-probe
2268+
- test-site-health-probe
22082269
- test-release-container-services
22092270
- build-boot-artifacts-x86
22102271
- build-boot-artifacts-bfb
@@ -2282,6 +2343,8 @@ jobs:
22822343
- merge-manifests-nvmetal-carbide
22832344
- build-machine-a-tron
22842345
- build-mat-k8s-controller
2346+
- build-site-health-probe
2347+
- test-site-health-probe
22852348
- test-release-container-services
22862349

22872350
# CLI images

crates/api-core/src/auth/internal_rbac_rules.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ enum RulePrincipal {
4545
Flow,
4646
MaintenanceJobs,
4747
DsxExchangeConsumer,
48-
Anonymous, // Permitted for everything
48+
SiteHealthProbe, // synthetic read-only monitoring (#5360)
49+
Anonymous, // Permitted for everything
4950
}
5051
use self::RulePrincipal::{
5152
Agent, Anonymous, BmcProxy, Dhcp, Dns, DsxExchangeConsumer, Flow, ForgeAdminCLI, Health,
52-
Machineatron, MaintenanceJobs, Pxe, Scout, SiteAgent, Ssh, SshRs,
53+
Machineatron, MaintenanceJobs, Pxe, Scout, SiteAgent, SiteHealthProbe, Ssh, SshRs,
5354
};
5455

5556
impl InternalRBACRules {
@@ -239,6 +240,7 @@ impl InternalRBACRules {
239240
Ssh,
240241
SshRs,
241242
Flow,
243+
SiteHealthProbe,
242244
],
243245
);
244246
x.perm(
@@ -251,6 +253,7 @@ impl InternalRBACRules {
251253
Ssh,
252254
SshRs,
253255
Flow,
256+
SiteHealthProbe,
254257
],
255258
);
256259
x.perm("FindConnectedDevicesByDpuMachineIds", vec![ForgeAdminCLI]);
@@ -1064,6 +1067,11 @@ impl RuleInfo {
10641067
"nico-dsx-exchange-consumer",
10651068
"carbide-dsx-exchange-consumer",
10661069
),
1070+
// New service (no legacy carbide- alias): the synthetic
1071+
// monitoring probe, read-only machine queries only (#5360).
1072+
RulePrincipal::SiteHealthProbe => vec![Principal::SpiffeServiceIdentifier(
1073+
"nico-site-health-probe".to_string(),
1074+
)],
10671075
RulePrincipal::Anonymous => vec![Principal::Anonymous],
10681076
})
10691077
.collect(),
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Build stage. Go 1.26.7: 1.26.4's standard library carries seven
5+
# govulncheck-flagged CVEs reachable from this service (crypto/tls, net/http,
6+
# net/url, encoding/asn1 — all fixed by 1.26.6/1.26.7), and the Grype image
7+
# scan reads the Go version from the binary.
8+
# --platform=$BUILDPLATFORM + GOARCH cross-compile: the builder always runs
9+
# natively and emits the target architecture, instead of compiling under QEMU
10+
# emulation (where the Go toolchain is unreliable).
11+
FROM --platform=$BUILDPLATFORM golang:1.26.7-alpine@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder
12+
13+
WORKDIR /app
14+
15+
COPY go.mod go.sum ./
16+
RUN go mod download
17+
18+
COPY . .
19+
ARG TARGETOS TARGETARCH
20+
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o site-health-probe ./cmd/site-health-probe
21+
22+
# Runtime stage
23+
FROM gcr.io/distroless/static:nonroot@sha256:f7f8f729987ad0fdf6b05eeeae94b26e6a0f613bdf46feea7fc40f7bd72953e6
24+
25+
COPY --from=builder /app/site-health-probe /site-health-probe
26+
27+
# Numeric UID (distroless "nonroot"): kubelet rejects named users under
28+
# runAsNonRoot ("cannot verify user is non-root").
29+
USER 65532:65532
30+
31+
ENTRYPOINT ["/site-health-probe"]

dev/k8s/site-health-probe/Makefile

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# nico-site-health-probe — synthetic monitoring for NICo APIs (issue #5360).
5+
6+
MODULE := github.com/NVIDIA/infra-controller/dev/k8s/site-health-probe
7+
PROTO_DIR := ../../../crates/rpc/proto
8+
PB_OUT := internal/forgepb
9+
IMAGE ?= site-health-probe:dev
10+
11+
# The gRPC proto contract is the repo-canonical crates/rpc/proto (same files the
12+
# Rust services compile). It is an internal, not-guaranteed API — this tool is
13+
# in-repo and version-locked to it, which is the supported way to consume it.
14+
# Only forge.proto declares a service; the rest are message dependencies.
15+
PROTOS := forge.proto common.proto dns.proto health.proto machine_discovery.proto \
16+
measured_boot.proto mlx_device.proto scout_firmware_upgrade.proto \
17+
site_explorer.proto codegen/v1/derive.proto codegen/v1/extern_path.proto
18+
19+
# None of the protos carry option go_package (they predate Go consumers), so the
20+
# mapping is supplied here. Each proto package gets its own Go package dir.
21+
M_FLAGS := \
22+
--go_opt=Mforge.proto=$(MODULE)/$(PB_OUT)/forge \
23+
--go_opt=Mcommon.proto=$(MODULE)/$(PB_OUT)/common \
24+
--go_opt=Mdns.proto=$(MODULE)/$(PB_OUT)/dns \
25+
--go_opt=Mhealth.proto=$(MODULE)/$(PB_OUT)/health \
26+
--go_opt=Mmachine_discovery.proto=$(MODULE)/$(PB_OUT)/machine_discovery \
27+
--go_opt=Mmeasured_boot.proto=$(MODULE)/$(PB_OUT)/measured_boot \
28+
--go_opt=Mmlx_device.proto=$(MODULE)/$(PB_OUT)/mlx_device \
29+
--go_opt=Mscout_firmware_upgrade.proto=$(MODULE)/$(PB_OUT)/scout_firmware_upgrade \
30+
--go_opt=Msite_explorer.proto=$(MODULE)/$(PB_OUT)/site_explorer \
31+
--go_opt=Mcodegen/v1/derive.proto=$(MODULE)/$(PB_OUT)/codegenv1 \
32+
--go_opt=Mcodegen/v1/extern_path.proto=$(MODULE)/$(PB_OUT)/codegenv1
33+
34+
GRPC_M_FLAGS := $(subst --go_opt=,--go-grpc_opt=,$(M_FLAGS))
35+
36+
.PHONY: proto-gen build test lint vet docker-build clean
37+
38+
proto-gen:
39+
rm -rf $(PB_OUT)
40+
mkdir -p $(PB_OUT)
41+
protoc -I $(PROTO_DIR) \
42+
--go_out=. --go_opt=module=$(MODULE) $(M_FLAGS) \
43+
--go-grpc_out=. --go-grpc_opt=module=$(MODULE) $(GRPC_M_FLAGS) \
44+
$(PROTOS)
45+
# forge.proto's MachineValidationStatus has oneof fields (started,
46+
# in_progress) AND nested enum values (Started, InProgress) that map to the
47+
# SAME Go identifier — legal in Rust codegen, a redeclaration in Go.
48+
# Disambiguate the enum-value constants to their full-path names. The two
49+
# patterns below are exactly the const declaration and its single bare use
50+
# (the oneof getter's default return); all other occurrences are the oneof
51+
# wrapper TYPE and must keep their names.
52+
perl -pi -e 's/^\tMachineValidationStatus_(Started|InProgress) MachineValidationStatus_MachineValidation\1 = 0$$/\tMachineValidationStatus_MachineValidation$${1}_$${1} MachineValidationStatus_MachineValidation$${1} = 0/' \
53+
$(PB_OUT)/forge/forge.pb.go
54+
perl -pi -e 's/^\treturn MachineValidationStatus_(Started|InProgress)$$/\treturn MachineValidationStatus_MachineValidation$${1}_$${1}/' \
55+
$(PB_OUT)/forge/forge.pb.go
56+
57+
build:
58+
CGO_ENABLED=0 go build -trimpath -o bin/site-health-probe ./cmd/site-health-probe
59+
60+
test:
61+
go test -race ./...
62+
63+
vet:
64+
go vet ./...
65+
66+
lint: vet
67+
@command -v golangci-lint >/dev/null && golangci-lint run ./... || echo "golangci-lint not installed — ran go vet only"
68+
69+
docker-build:
70+
docker build -t $(IMAGE) .
71+
72+
clean:
73+
rm -rf bin

0 commit comments

Comments
 (0)