Skip to content

Commit 022201f

Browse files
authored
fix: context deadline exceeded shouldn't throw an error (#203)
Signed-off-by: Amber Xue <ambermingxin@nvidia.com>
1 parent 18dfbe6 commit 022201f

8 files changed

Lines changed: 203 additions & 2 deletions

File tree

deployments/helm/fleet-intelligence-agent/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ Common values (defaults from `values.yaml`):
5151
| `enroll.tokenSecretName` | `""` | Secret name for enrollment token. |
5252
| `enroll.tokenSecretKey` | `token` | Secret key for enrollment token. |
5353
| `enroll.tokenValue` | `""` | Inline token value (optional). |
54+
| `enroll.nodeGroup` | `(not set)` | Optional `--node-group` enroll flag. Omit key to omit flag, set `""` to clear stored value. |
55+
| `enroll.computeZone` | `(not set)` | Optional `--compute-zone` enroll flag. Omit key to omit flag, set `""` to clear stored value. |
5456
| `enroll.securityContext.runAsUser` | `0` | Run enrollment init as root. |
5557
| `ports.http` | `15133` | HTTP port. |
5658
| `resources.requests.cpu` | `100m` | CPU request. |
@@ -70,6 +72,7 @@ Common values (defaults from `values.yaml`):
7072

7173
`enroll.enabled` and `enroll.unenroll` are mutually exclusive; do not set both to `true`.
7274
Set `enroll.force=true` to append `--force` to `fleetint enroll`.
75+
Set `enroll.nodeGroup` / `enroll.computeZone` to pass optional enrollment metadata via the init container command.
7376

7477
See `docs/install-helm.md` for the enrollment flow and secret creation steps.
7578

deployments/helm/fleet-intelligence-agent/templates/daemonset.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@ spec:
7474
{{- if .Values.enroll.force }}
7575
--force
7676
{{- end }}
77+
{{- if hasKey .Values.enroll "nodeGroup" }}
78+
--node-group {{ .Values.enroll.nodeGroup | quote }}
79+
{{- end }}
80+
{{- if hasKey .Values.enroll "computeZone" }}
81+
--compute-zone {{ .Values.enroll.computeZone | quote }}
82+
{{- end }}
7783
securityContext:
7884
{{- toYaml .Values.securityContext | nindent 12 }}
7985
env:

deployments/helm/fleet-intelligence-agent/values.yaml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ enroll:
6161
tokenSecretName: ""
6262
tokenSecretKey: "token"
6363
tokenValue: ""
64+
# Optional enrollment metadata:
65+
# - omit key to preserve existing stored value
66+
# - set non-empty string to overwrite
67+
# - set empty string ("") to clear
68+
#
69+
# nodeGroup: "prod-a"
70+
# computeZone: "us-east-1c"
6471

6572
ports:
6673
http: 15133

docs/install-helm.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,23 @@ helm upgrade fleet-intelligence-agent oci://ghcr.io/nvidia/charts/fleet-intellig
7575
--set enroll.tokenSecretName="$ENROLL_TOKEN_SECRET_NAME"
7676
```
7777

78+
Optional: include node metadata during automatic enrollment:
79+
80+
```bash
81+
helm upgrade fleet-intelligence-agent oci://ghcr.io/nvidia/charts/fleet-intelligence-agent \
82+
--version "$CHART_VERSION" \
83+
--namespace "$NS" \
84+
--set enroll.enabled=true \
85+
--set enroll.endpoint="$ENROLL_ENDPOINT" \
86+
--set enroll.tokenSecretName="$ENROLL_TOKEN_SECRET_NAME" \
87+
--set-string enroll.nodeGroup="prod-a" \
88+
--set-string enroll.computeZone="us-east-1c"
89+
```
90+
91+
Notes:
92+
- Omit `enroll.nodeGroup` / `enroll.computeZone` keys to omit the flags and preserve existing stored values.
93+
- Set either value to an empty string to clear it (for example: `--set-string enroll.nodeGroup=""`).
94+
7895
Upgrade (no enrollment):
7996

8097
```bash

internal/exporter/exporter.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,16 @@ func (e *healthExporter) Start() error {
116116

117117
log.Logger.Infow("Starting health exporter")
118118

119+
// In offline mode, emit one export immediately so short runs don't exit
120+
// before the first ticker cycle and leave an empty output directory.
121+
if e.options.config.OfflineMode {
122+
if err := e.export(); err != nil {
123+
log.Logger.Errorw("Initial offline export failed", "error", err)
124+
} else {
125+
e.lastExport = time.Now().UTC()
126+
}
127+
}
128+
119129
// Start the health export ticker
120130
go func() {
121131
ticker := time.NewTicker(e.options.config.Interval.Duration)

internal/exporter/exporter_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,85 @@ func TestStart(t *testing.T) {
284284
require.NoError(t, err)
285285
})
286286

287+
t.Run("offline mode performs initial export before first tick", func(t *testing.T) {
288+
tmpDir := t.TempDir()
289+
cfg := &config.HealthExporterConfig{
290+
Interval: metav1.Duration{Duration: 1 * time.Hour},
291+
Timeout: metav1.Duration{Duration: 30 * time.Second},
292+
OfflineMode: true,
293+
OutputPath: tmpDir,
294+
OutputFormat: "json",
295+
}
296+
297+
exporter, err := New(ctx, WithConfig(cfg), WithMachineID("test-machine-id"))
298+
require.NoError(t, err)
299+
require.NotNil(t, exporter)
300+
301+
err = exporter.Start()
302+
require.NoError(t, err)
303+
304+
// Initial export should be written immediately, without waiting for ticker.
305+
time.Sleep(100 * time.Millisecond)
306+
entries, err := os.ReadDir(tmpDir)
307+
require.NoError(t, err)
308+
assert.Greater(t, len(entries), 0, "Expected offline files from initial export")
309+
310+
err = exporter.Stop()
311+
require.NoError(t, err)
312+
})
313+
314+
t.Run("offline initial export includes collected data", func(t *testing.T) {
315+
tmpDir := t.TempDir()
316+
cfg := &config.HealthExporterConfig{
317+
Interval: metav1.Duration{Duration: 1 * time.Hour},
318+
Timeout: metav1.Duration{Duration: 30 * time.Second},
319+
OfflineMode: true,
320+
OutputPath: tmpDir,
321+
OutputFormat: "json",
322+
}
323+
324+
exporter, err := New(ctx, WithConfig(cfg), WithMachineID("test-machine-id"))
325+
require.NoError(t, err)
326+
require.NotNil(t, exporter)
327+
328+
he := exporter.(*healthExporter)
329+
mockCollector := &MockCollector{}
330+
mockCollector.On("Collect", mock.Anything).Return(&collector.HealthData{
331+
MachineID: "test-machine",
332+
Timestamp: time.Now(),
333+
Metrics: []pkgmetrics.Metric{
334+
{
335+
Name: "test_metric",
336+
Value: 42.0,
337+
UnixMilliseconds: time.Now().UnixMilli(),
338+
},
339+
},
340+
}, nil).Once()
341+
he.collector = mockCollector
342+
343+
err = exporter.Start()
344+
require.NoError(t, err)
345+
346+
entries, err := os.ReadDir(tmpDir)
347+
require.NoError(t, err)
348+
require.Greater(t, len(entries), 0, "Expected offline files from initial export")
349+
350+
allContent := ""
351+
for _, entry := range entries {
352+
fullPath := filepath.Join(tmpDir, entry.Name())
353+
content, readErr := os.ReadFile(fullPath)
354+
require.NoError(t, readErr)
355+
allContent += string(content)
356+
}
357+
assert.Contains(t, allContent, "test_metric")
358+
assert.Contains(t, allContent, "machine.id")
359+
360+
mockCollector.AssertExpectations(t)
361+
362+
err = exporter.Stop()
363+
require.NoError(t, err)
364+
})
365+
287366
}
288367

289368
// TestStop tests the Stop function

internal/server/server.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ type Server struct {
8989
// signal handler in run.go both call Stop(), so without this guard
9090
// components, databases, and the health exporter would be closed twice.
9191
stopOnce sync.Once
92+
loopWG sync.WaitGroup
93+
94+
// loopCtx/loopCancel control background inventory and attestation goroutines.
95+
// Stop() cancels this context and waits on loopWG for graceful shutdown.
96+
loopCtx context.Context
97+
loopCancel context.CancelFunc
9298

9399
machineID string
94100
}
@@ -210,6 +216,24 @@ func getAttestationTimeout(cfg *config.Config) time.Duration {
210216
return config.DefaultAttestationTimeout
211217
}
212218

219+
func waitForWaitGroup(wg *sync.WaitGroup, timeout time.Duration) bool {
220+
done := make(chan struct{})
221+
go func() {
222+
wg.Wait()
223+
close(done)
224+
}()
225+
if timeout <= 0 {
226+
<-done
227+
return true
228+
}
229+
select {
230+
case <-done:
231+
return true
232+
case <-time.After(timeout):
233+
return false
234+
}
235+
}
236+
213237
// shouldEnableComponent determines if a component should be enabled based on configuration
214238
func shouldEnableComponent(name string, enabledByDefault bool, config *config.Config) bool {
215239
shouldEnable := enabledByDefault
@@ -235,11 +259,14 @@ func New(ctx context.Context, auditLogger log.AuditLogger, config *config.Config
235259
return nil, err
236260
}
237261

262+
loopCtx, loopCancel := context.WithCancel(ctx)
238263
s := &Server{
239264
auditLogger: auditLogger,
240265
dbRW: dbRW,
241266
dbRO: dbRO,
242267
config: config,
268+
loopCtx: loopCtx,
269+
loopCancel: loopCancel,
243270
}
244271
defer func() {
245272
if retErr != nil {
@@ -383,8 +410,8 @@ func New(ctx context.Context, auditLogger log.AuditLogger, config *config.Config
383410
}
384411
}
385412

386-
s.startInventoryLoop(ctx, config, nvmlInstance, dcgmGPUIndexes)
387-
s.startAttestationLoop(ctx, config)
413+
s.startInventoryLoop(loopCtx, config, nvmlInstance, dcgmGPUIndexes)
414+
s.startAttestationLoop(loopCtx, config)
388415

389416
// Create and start health exporter with all dependencies if enabled
390417
if config.HealthExporter != nil {
@@ -462,7 +489,9 @@ func (s *Server) startInventoryLoop(
462489
StartupJitter: inventory.DefaultStartupJitter,
463490
})
464491

492+
s.loopWG.Add(1)
465493
go func() {
494+
defer s.loopWG.Done()
466495
if err := manager.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
467496
log.Logger.Errorw("inventory loop manager exited", "error", err)
468497
}
@@ -497,7 +526,9 @@ func (s *Server) startAttestationLoop(ctx context.Context, cfg *config.Config) {
497526
},
498527
)
499528

529+
s.loopWG.Add(1)
500530
go func() {
531+
defer s.loopWG.Done()
501532
if err := manager.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
502533
log.Logger.Errorw("attestation loop exited", "error", err)
503534
}
@@ -516,6 +547,15 @@ func (s *Server) GetHealthExporter() exporter.Exporter {
516547
// signal handler in run.go both invoke it.
517548
func (s *Server) Stop() {
518549
s.stopOnce.Do(func() {
550+
// Signal inventory/attestation loops to stop and wait for graceful exit
551+
// before we close dependencies they may still be using.
552+
if s.loopCancel != nil {
553+
s.loopCancel()
554+
}
555+
if !waitForWaitGroup(&s.loopWG, 10*time.Second) {
556+
log.Logger.Warnw("timed out waiting for background loops to stop")
557+
}
558+
519559
// Gracefully shut down the HTTP server so in-flight requests complete
520560
// before we close databases and components underneath them.
521561
if s.srv != nil {

internal/server/server_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"net"
2121
"os"
2222
"path/filepath"
23+
"sync"
2324
"testing"
2425
"time"
2526

@@ -158,6 +159,26 @@ func TestGetInventorySyncTimeout(t *testing.T) {
158159
}
159160
}
160161

162+
func TestWaitForWaitGroup(t *testing.T) {
163+
t.Run("returns true when waitgroup completes", func(t *testing.T) {
164+
var wg sync.WaitGroup
165+
wg.Add(1)
166+
go func() {
167+
defer wg.Done()
168+
time.Sleep(10 * time.Millisecond)
169+
}()
170+
assert.True(t, waitForWaitGroup(&wg, 200*time.Millisecond))
171+
})
172+
173+
t.Run("returns false on timeout", func(t *testing.T) {
174+
var wg sync.WaitGroup
175+
wg.Add(1)
176+
assert.False(t, waitForWaitGroup(&wg, 10*time.Millisecond))
177+
wg.Done()
178+
assert.True(t, waitForWaitGroup(&wg, 100*time.Millisecond))
179+
})
180+
}
181+
161182
func TestGetAttestationSettings(t *testing.T) {
162183
tests := []struct {
163184
name string
@@ -439,6 +460,24 @@ func TestServerStop(t *testing.T) {
439460
}
440461
}
441462

463+
func TestServerStopCancelsBackgroundLoops(t *testing.T) {
464+
loopCtx, loopCancel := context.WithCancel(context.Background())
465+
s := &Server{
466+
loopCtx: loopCtx,
467+
loopCancel: loopCancel,
468+
}
469+
s.loopWG.Add(1)
470+
go func() {
471+
defer s.loopWG.Done()
472+
<-loopCtx.Done()
473+
}()
474+
475+
s.Stop()
476+
477+
assert.ErrorIs(t, loopCtx.Err(), context.Canceled)
478+
assert.True(t, waitForWaitGroup(&s.loopWG, 100*time.Millisecond))
479+
}
480+
442481
// TestServerStopWithDatabases tests Stop with actual database connections.
443482
func TestServerStopWithDatabases(t *testing.T) {
444483
ctx := context.Background()

0 commit comments

Comments
 (0)