diff --git a/deployments/helm/fleet-intelligence-agent/templates/daemonset.yaml b/deployments/helm/fleet-intelligence-agent/templates/daemonset.yaml index 8c059f83..1e738e43 100644 --- a/deployments/helm/fleet-intelligence-agent/templates/daemonset.yaml +++ b/deployments/helm/fleet-intelligence-agent/templates/daemonset.yaml @@ -101,6 +101,9 @@ spec: volumeMounts: - name: dev mountPath: /dev + - name: sys + mountPath: /sys + readOnly: true - name: state mountPath: /var/lib/fleetint {{- else if .Values.enroll.unenroll }} diff --git a/go.mod b/go.mod index e70c18eb..f21f8939 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/olekukonko/tablewriter v0.0.5 github.com/prometheus/client_golang v1.23.0 + github.com/prometheus/procfs v0.16.1 github.com/stretchr/testify v1.11.1 github.com/urfave/cli v1.22.17 go.opentelemetry.io/proto/otlp v1.8.0 @@ -76,7 +77,6 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect - github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.57.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect diff --git a/internal/agentstate/sqlite.go b/internal/agentstate/sqlite.go index 523c682f..5f87e46e 100644 --- a/internal/agentstate/sqlite.go +++ b/internal/agentstate/sqlite.go @@ -112,6 +112,85 @@ func (s *sqliteState) SetNodeUUID(ctx context.Context, value string) error { return s.setMetadata(ctx, pkgmetadata.MetadataKeyMachineID, value) } +func (s *sqliteState) GetOrCreateNodeUUID(ctx context.Context, create func() (string, error)) (string, bool, error) { + db, err := s.openReadWrite() + if err != nil { + return "", false, err + } + defer db.Close() + + if stateFile, err := s.stateFileFn(); err == nil { + if err := config.SecureStateFilePermissions(stateFile); err != nil { + return "", false, fmt.Errorf("secure state file permissions: %w", err) + } + } + if err := pkgmetadata.CreateTableMetadata(ctx, db); err != nil { + return "", false, fmt.Errorf("create metadata table: %w", err) + } + + return GetOrCreateNodeUUIDMetadata(ctx, db, create) +} + +// GetOrCreateNodeUUIDMetadata atomically returns the committed node UUID +// metadata value, creating it when missing or repairing an empty value. +func GetOrCreateNodeUUIDMetadata(ctx context.Context, db *sql.DB, create func() (string, error)) (string, bool, error) { + if create == nil { + return "", false, fmt.Errorf("node UUID create function is required") + } + + existing, ok, err := readNodeUUIDDB(ctx, db) + if err != nil { + return "", false, err + } else if ok { + return existing, false, nil + } + + candidate, err := create() + if err != nil { + return "", false, err + } + if candidate == "" { + return "", false, fmt.Errorf("created node UUID is empty") + } + if _, err := db.ExecContext(ctx, ` +INSERT INTO gpud_metadata (key, value) VALUES (?, ?) +ON CONFLICT(key) DO UPDATE SET value = excluded.value +WHERE gpud_metadata.value = ''`, + pkgmetadata.MetadataKeyMachineID, + candidate, + ); err != nil { + return "", false, fmt.Errorf("insert node UUID metadata: %w", err) + } + + committed, ok, err := readNodeUUIDDB(ctx, db) + if err != nil { + return "", false, err + } else if !ok { + return "", false, fmt.Errorf("node UUID metadata was not committed") + } else if committed == "" { + return "", false, fmt.Errorf("node UUID metadata is empty") + } + return committed, committed == candidate, nil +} + +func readNodeUUIDDB(ctx context.Context, db *sql.DB) (string, bool, error) { + var value string + err := db.QueryRowContext(ctx, ` +SELECT value FROM gpud_metadata WHERE key = ?`, + pkgmetadata.MetadataKeyMachineID, + ).Scan(&value) + switch { + case err == nil && value != "": + return value, true, nil + case err == nil: + return "", false, nil + case errors.Is(err, sql.ErrNoRows): + return "", false, nil + default: + return "", false, fmt.Errorf("read node UUID metadata: %w", err) + } +} + func (s *sqliteState) GetNodeGroup(ctx context.Context) (string, bool, error) { return s.getMetadata(ctx, MetadataKeyNodeGroup) } @@ -174,8 +253,7 @@ func (s *sqliteState) setMetadata(ctx context.Context, key, value string) error } defer db.Close() - stateFile, err := s.stateFileFn() - if err == nil { + if stateFile, err := s.stateFileFn(); err == nil { if err := config.SecureStateFilePermissions(stateFile); err != nil { return fmt.Errorf("secure state file permissions: %w", err) } diff --git a/internal/agentstate/sqlite_test.go b/internal/agentstate/sqlite_test.go index 724fc689..c59c9fe8 100644 --- a/internal/agentstate/sqlite_test.go +++ b/internal/agentstate/sqlite_test.go @@ -120,6 +120,44 @@ func TestSQLiteStateMissingValue(t *testing.T) { require.Empty(t, value) } +func TestSQLiteStateGetOrCreateNodeUUID(t *testing.T) { + t.Parallel() + + ctx := context.Background() + state := newTestSQLiteState(t) + + value, created, err := state.GetOrCreateNodeUUID(ctx, func() (string, error) { + return "node-created", nil + }) + require.NoError(t, err) + require.True(t, created) + require.Equal(t, "node-created", value) + + value, created, err = state.GetOrCreateNodeUUID(ctx, func() (string, error) { + t.Fatal("create should not run when node UUID is already persisted") + return "", nil + }) + require.NoError(t, err) + require.False(t, created) + require.Equal(t, "node-created", value) + + emptyRowState := newTestSQLiteState(t) + err = emptyRowState.SetNodeUUID(ctx, "") + require.NoError(t, err) + + value, created, err = emptyRowState.GetOrCreateNodeUUID(ctx, func() (string, error) { + return "node-repaired", nil + }) + require.NoError(t, err) + require.True(t, created) + require.Equal(t, "node-repaired", value) + + value, ok, err := emptyRowState.GetNodeUUID(ctx) + require.NoError(t, err) + require.True(t, ok) + require.Equal(t, "node-repaired", value) +} + func TestSQLiteStateMissingMetadataTableIsTreatedAsAbsent(t *testing.T) { t.Parallel() diff --git a/internal/agentstate/state.go b/internal/agentstate/state.go index 2f705f3d..1e3230a6 100644 --- a/internal/agentstate/state.go +++ b/internal/agentstate/state.go @@ -40,8 +40,11 @@ type State interface { GetSAK(ctx context.Context) (value string, ok bool, err error) SetSAK(ctx context.Context, value string) error + // GetNodeUUID reads the committed node UUID without creating one. GetNodeUUID(ctx context.Context) (value string, ok bool, err error) SetNodeUUID(ctx context.Context, value string) error + // GetOrCreateNodeUUID creates and commits the node UUID when no value exists. + GetOrCreateNodeUUID(ctx context.Context, create func() (string, error)) (value string, created bool, err error) GetEnrollmentTime(ctx context.Context) (value time.Time, ok bool, err error) SetEnrollmentTime(ctx context.Context, value time.Time) error diff --git a/internal/attestation/backend_test.go b/internal/attestation/backend_test.go index 86b109ae..1066dea7 100644 --- a/internal/attestation/backend_test.go +++ b/internal/attestation/backend_test.go @@ -51,6 +51,21 @@ func (s *stubState) GetNodeUUID(context.Context) (string, bool, error) { return s.nodeUUID, s.nodeOK, s.nodeErr } func (s *stubState) SetNodeUUID(context.Context, string) error { return nil } +func (s *stubState) GetOrCreateNodeUUID(_ context.Context, create func() (string, error)) (string, bool, error) { + if s.nodeErr != nil { + return "", false, s.nodeErr + } + if s.nodeOK && s.nodeUUID != "" { + return s.nodeUUID, false, nil + } + value, err := create() + if err != nil { + return "", false, err + } + s.nodeUUID = value + s.nodeOK = true + return value, true, nil +} func (s *stubState) GetNodeGroup(context.Context) (string, bool, error) { return "", false, nil } diff --git a/internal/enrollment/enrollment.go b/internal/enrollment/enrollment.go index 682494f9..4f988f7d 100644 --- a/internal/enrollment/enrollment.go +++ b/internal/enrollment/enrollment.go @@ -36,12 +36,15 @@ import ( inventorysink "github.com/NVIDIA/fleet-intelligence-agent/internal/inventory/sink" inventorysource "github.com/NVIDIA/fleet-intelligence-agent/internal/inventory/source" "github.com/NVIDIA/fleet-intelligence-agent/internal/machineinfo" + "github.com/NVIDIA/fleet-intelligence-agent/internal/nodeidentity" "github.com/NVIDIA/fleet-intelligence-agent/internal/registry" ) var ( newBackendClient = backendclient.New syncInventoryAfterEnroll = syncInventoryOnce + ensureNodeUUID = nodeidentity.EnsureNodeUUID + storeEnrollmentConfig = storeConfigInMetadata postEnrollInventorySyncTimeout = time.Minute ) @@ -76,8 +79,11 @@ func EnrollWithConfigAndMetadata(ctx context.Context, baseEndpoint, sakToken str if err != nil { return err } + if _, err := ensureNodeUUID(ctx, agentstate.NewSQLite()); err != nil { + return fmt.Errorf("failed to initialize node UUID: %w", err) + } enrolledAt := time.Now().UTC() - if err := storeConfigInMetadata(ctx, baseURL.String(), jwtToken, sakToken, enrolledAt, normalizedEnrollMetadata(metadata)); err != nil { + if err := storeEnrollmentConfig(ctx, baseURL.String(), jwtToken, sakToken, enrolledAt, normalizedEnrollMetadata(metadata)); err != nil { return fmt.Errorf("failed to store configuration: %w", err) } syncCtx, cancel := context.WithTimeout(ctx, postEnrollInventorySyncTimeout) diff --git a/internal/enrollment/enrollment_test.go b/internal/enrollment/enrollment_test.go index a01f7b72..6b0fcda8 100644 --- a/internal/enrollment/enrollment_test.go +++ b/internal/enrollment/enrollment_test.go @@ -28,6 +28,7 @@ import ( "github.com/NVIDIA/fleet-intelligence-agent/internal/agentstate" "github.com/NVIDIA/fleet-intelligence-agent/internal/backendclient" "github.com/NVIDIA/fleet-intelligence-agent/internal/config" + "github.com/NVIDIA/fleet-intelligence-agent/internal/nodeidentity" ) type fakeBackendClient struct { @@ -60,9 +61,11 @@ func (f *fakeBackendClient) SubmitAttestation(context.Context, string, *backendc func TestEnrollWorkflow(t *testing.T) { originalFactory := newBackendClient originalSync := syncInventoryAfterEnroll + originalEnsure := ensureNodeUUID t.Cleanup(func() { newBackendClient = originalFactory syncInventoryAfterEnroll = originalSync + ensureNodeUUID = originalEnsure }) client := &fakeBackendClient{enrollJWT: "jwt-token"} @@ -72,7 +75,14 @@ func TestEnrollWorkflow(t *testing.T) { } syncCalled := false + ensureCalled := false + ensureNodeUUID = func(ctx context.Context, state nodeidentity.State) (string, error) { + require.NotNil(t, state) + ensureCalled = true + return "4c4c4544-0053-5210-8038-c8c04f583034", nil + } syncInventoryAfterEnroll = func(ctx context.Context, cfg *config.Config) error { + require.True(t, ensureCalled, "node UUID should be initialized before post-enroll inventory sync") syncCalled = true return nil } @@ -83,6 +93,7 @@ func TestEnrollWorkflow(t *testing.T) { err := Enroll(context.Background(), "https://example.com", "sak-token") require.NoError(t, err) require.Equal(t, "sak-token", client.enrollSAK) + require.True(t, ensureCalled) require.True(t, syncCalled) } @@ -340,6 +351,39 @@ func TestEnrollWorkflowErrors(t *testing.T) { require.ErrorContains(t, err, "enroll boom") }) + t.Run("node UUID initialization", func(t *testing.T) { + originalFactory := newBackendClient + originalEnsure := ensureNodeUUID + originalSync := syncInventoryAfterEnroll + originalStore := storeEnrollmentConfig + t.Cleanup(func() { + newBackendClient = originalFactory + ensureNodeUUID = originalEnsure + syncInventoryAfterEnroll = originalSync + storeEnrollmentConfig = originalStore + }) + newBackendClient = func(rawBaseURL string) (backendclient.Client, error) { + return &fakeBackendClient{enrollJWT: "jwt-token"}, nil + } + ensureNodeUUID = func(context.Context, nodeidentity.State) (string, error) { + return "", errors.New("identity store failed") + } + syncInventoryAfterEnroll = func(context.Context, *config.Config) error { + t.Fatal("inventory sync should not run when node UUID initialization fails") + return nil + } + storeEnrollmentConfig = func(context.Context, string, string, string, time.Time, EnrollMetadata) error { + t.Fatal("enrollment metadata should not be stored when node UUID initialization fails") + return nil + } + + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + err := Enroll(context.Background(), "https://example.com", "sak-token") + require.ErrorContains(t, err, "failed to initialize node UUID") + }) + t.Run("localhost legacy endpoint allowed", func(t *testing.T) { originalFactory := newBackendClient originalSync := syncInventoryAfterEnroll diff --git a/internal/inventory/sink/backend_test.go b/internal/inventory/sink/backend_test.go index 12713457..628f8593 100644 --- a/internal/inventory/sink/backend_test.go +++ b/internal/inventory/sink/backend_test.go @@ -63,6 +63,20 @@ func (f *fakeState) GetNodeUUID(context.Context) (string, bool, error) { return f.nodeUUID, f.nodeUUID != "", nil } func (f *fakeState) SetNodeUUID(context.Context, string) error { return nil } +func (f *fakeState) GetOrCreateNodeUUID(_ context.Context, create func() (string, error)) (string, bool, error) { + if f.err != nil { + return "", false, f.err + } + if f.nodeUUID != "" { + return f.nodeUUID, false, nil + } + value, err := create() + if err != nil { + return "", false, err + } + f.nodeUUID = value + return value, true, nil +} func (f *fakeState) GetNodeGroup(context.Context) (string, bool, error) { if f.nodeGroupErr != nil { return "", false, f.nodeGroupErr diff --git a/internal/nodeidentity/nodeidentity.go b/internal/nodeidentity/nodeidentity.go new file mode 100644 index 00000000..153e1a2e --- /dev/null +++ b/internal/nodeidentity/nodeidentity.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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 nodeidentity initializes and persists the stable node UUID used by +// enrollment, inventory, attestation, and metrics workflows. +// The persisted node UUID is stored in the legacy machine ID metadata key. +package nodeidentity + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/prometheus/procfs/sysfs" + + "github.com/NVIDIA/fleet-intelligence-sdk/pkg/log" +) + +// State is the local storage contract needed to persist node identity. +type State interface { + GetOrCreateNodeUUID(ctx context.Context, create func() (string, error)) (value string, created bool, err error) +} + +var ( + readHardwareUUID = readDMIProductUUID + newNodeUUID = uuid.NewString +) + +// EnsureNodeUUID returns the persisted node UUID, or initializes and persists it +// from DMI sysfs with a random UUID fallback. +func EnsureNodeUUID(ctx context.Context, state State) (string, error) { + if state == nil { + return "", errors.New("node identity state is required") + } + + nodeUUID, created, err := state.GetOrCreateNodeUUID(ctx, func() (string, error) { + nodeUUID, err := readHardwareUUID() + if err != nil || nodeUUID == "" { + nodeUUID = newNodeUUID() + log.Logger.Warnw("Failed to get hardware UUID, generated random agent ID", + "error", err, + "generated_id", nodeUUID) + } else { + log.Logger.Infow("Initialized agent ID from hardware UUID", "machine_id", nodeUUID) + } + return nodeUUID, nil + }) + if err != nil { + return "", fmt.Errorf("failed to ensure node UUID: %w", err) + } + if created { + log.Logger.Infow("Persisted agent ID to database", "machine_id", nodeUUID) + } else { + log.Logger.Infow("Using persisted agent ID from database", "machine_id", nodeUUID) + } + return nodeUUID, nil +} + +func readDMIProductUUID() (string, error) { + return readDMIProductUUIDFromFS("/sys") +} + +func readDMIProductUUIDFromFS(root string) (string, error) { + fs, err := sysfs.NewFS(root) + if err != nil { + return "", fmt.Errorf("failed to open sysfs: %w", err) + } + + dmi, err := fs.DMIClass() + if err != nil { + return "", fmt.Errorf("failed to read DMI class: %w", err) + } + if dmi.ProductUUID == nil { + return "", errors.New("DMI product UUID not found") + } + + nodeUUID := strings.TrimSpace(*dmi.ProductUUID) + if nodeUUID == "" { + return "", errors.New("DMI product UUID is empty") + } + parsedUUID, err := uuid.Parse(nodeUUID) + if err != nil { + return "", fmt.Errorf("invalid DMI product UUID %q: %w", nodeUUID, err) + } + if parsedUUID == uuid.Nil { + return "", errors.New("DMI product UUID is unset") + } + return nodeUUID, nil +} diff --git a/internal/nodeidentity/nodeidentity_test.go b/internal/nodeidentity/nodeidentity_test.go new file mode 100644 index 00000000..783fceb4 --- /dev/null +++ b/internal/nodeidentity/nodeidentity_test.go @@ -0,0 +1,203 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// 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 nodeidentity + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeState struct { + nodeUUID string + ok bool + err error + calls int + commitAs string +} + +func (s *fakeState) GetOrCreateNodeUUID(_ context.Context, create func() (string, error)) (string, bool, error) { + s.calls++ + if s.err != nil { + return "", false, s.err + } + if s.ok && s.nodeUUID != "" { + return s.nodeUUID, false, nil + } + value, err := create() + if err != nil { + return "", false, err + } + created := s.commitAs == "" + if s.commitAs != "" { + value = s.commitAs + } + s.nodeUUID = value + s.ok = true + return value, created, nil +} + +func writeTestDMIProductUUID(t *testing.T, root, id string) { + t.Helper() + + dmiIDDir := filepath.Join(root, "class", "dmi", "id") + require.NoError(t, os.MkdirAll(dmiIDDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dmiIDDir, "product_uuid"), []byte(id), 0o644)) +} + +func overrideUUIDSources(t *testing.T, read func() (string, error), generate func() string) { + t.Helper() + + previousRead := readHardwareUUID + previousGenerate := newNodeUUID + readHardwareUUID = read + newNodeUUID = generate + t.Cleanup(func() { + readHardwareUUID = previousRead + newNodeUUID = previousGenerate + }) +} + +func TestReadDMIProductUUIDFromFS(t *testing.T) { + const validUUID = "4c4c4544-0053-5210-8038-c8c04f583034" + + t.Run("reads product uuid", func(t *testing.T) { + root := t.TempDir() + writeTestDMIProductUUID(t, root, validUUID+"\n") + + id, err := readDMIProductUUIDFromFS(root) + require.NoError(t, err) + assert.Equal(t, validUUID, id) + }) + + t.Run("errors when product uuid is missing", func(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "class", "dmi", "id"), 0o755)) + + id, err := readDMIProductUUIDFromFS(root) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "DMI product UUID not found") + }) + + t.Run("errors when product uuid is invalid", func(t *testing.T) { + root := t.TempDir() + writeTestDMIProductUUID(t, root, "Not Settable\n") + + id, err := readDMIProductUUIDFromFS(root) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "invalid DMI product UUID") + }) + + t.Run("errors when product uuid is zero", func(t *testing.T) { + root := t.TempDir() + writeTestDMIProductUUID(t, root, "00000000-0000-0000-0000-000000000000\n") + + id, err := readDMIProductUUIDFromFS(root) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "DMI product UUID is unset") + }) +} + +func TestEnsureNodeUUID(t *testing.T) { + const ( + dmiUUID = "4c4c4544-0053-5210-8038-c8c04f583034" + generatedID = "6f459d44-ee11-44c1-b633-e810390ab3f7" + ) + + t.Run("uses persisted UUID", func(t *testing.T) { + state := &fakeState{nodeUUID: dmiUUID, ok: true} + overrideUUIDSources(t, func() (string, error) { + t.Fatal("hardware UUID should not be read when UUID is already persisted") + return "", nil + }, func() string { + t.Fatal("random UUID should not be generated when UUID is already persisted") + return "" + }) + + id, err := EnsureNodeUUID(context.Background(), state) + require.NoError(t, err) + assert.Equal(t, dmiUUID, id) + assert.Equal(t, 1, state.calls) + }) + + t.Run("persists DMI product UUID", func(t *testing.T) { + state := &fakeState{} + overrideUUIDSources(t, func() (string, error) { + return dmiUUID, nil + }, func() string { + return generatedID + }) + + id, err := EnsureNodeUUID(context.Background(), state) + require.NoError(t, err) + assert.Equal(t, dmiUUID, id) + assert.Equal(t, dmiUUID, state.nodeUUID) + assert.Equal(t, 1, state.calls) + }) + + t.Run("persists random UUID when DMI fails", func(t *testing.T) { + state := &fakeState{} + overrideUUIDSources(t, func() (string, error) { + return "", errors.New("dmi unavailable") + }, func() string { + return generatedID + }) + + id, err := EnsureNodeUUID(context.Background(), state) + require.NoError(t, err) + assert.Equal(t, generatedID, id) + assert.Equal(t, generatedID, state.nodeUUID) + assert.Equal(t, 1, state.calls) + }) + + t.Run("returns committed UUID from state", func(t *testing.T) { + const committedID = "3758d746-9bad-4f47-b240-ab73d6f68c9a" + state := &fakeState{commitAs: committedID} + overrideUUIDSources(t, func() (string, error) { + return dmiUUID, nil + }, func() string { + return generatedID + }) + + id, err := EnsureNodeUUID(context.Background(), state) + require.NoError(t, err) + assert.Equal(t, committedID, id) + assert.Equal(t, committedID, state.nodeUUID) + assert.Equal(t, 1, state.calls) + }) + + t.Run("propagates state error", func(t *testing.T) { + state := &fakeState{err: errors.New("write failed")} + overrideUUIDSources(t, func() (string, error) { + return dmiUUID, nil + }, func() string { + return generatedID + }) + + id, err := EnsureNodeUUID(context.Background(), state) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "failed to ensure node UUID") + }) +} diff --git a/internal/server/server.go b/internal/server/server.go index adabeb98..cabe6246 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -34,7 +34,6 @@ import ( "github.com/gin-contrib/gzip" "github.com/gin-gonic/gin" - "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/NVIDIA/fleet-intelligence-sdk/components" @@ -61,6 +60,7 @@ import ( inventorysink "github.com/NVIDIA/fleet-intelligence-agent/internal/inventory/sink" inventorysource "github.com/NVIDIA/fleet-intelligence-agent/internal/inventory/source" "github.com/NVIDIA/fleet-intelligence-agent/internal/machineinfo" + "github.com/NVIDIA/fleet-intelligence-agent/internal/nodeidentity" "github.com/NVIDIA/fleet-intelligence-agent/internal/registry" ) @@ -137,40 +137,23 @@ func initializeDatabases(ctx context.Context, cfg *config.Config) (*sql.DB, *sql return dbRW, dbRO, nil } -// initializeMachineID retrieves or creates a machine ID -// This establishes the agent's stable identity for metrics reporting. -// Priority: DB (persisted) → dmidecode (hardware UUID) → random UUID -func initializeMachineID(ctx context.Context, dbRW, dbRO *sql.DB) (string, error) { - // Try to read existing machine ID from database - machineID, err := pkgmetadata.ReadMachineID(ctx, dbRO) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return "", fmt.Errorf("failed to read machine uid: %w", err) - } - - // If no machine ID found in database, initialize a new one - if machineID == "" { - // First, try to get hardware UUID from dmidecode - machineID, err = pkghost.GetDmidecodeUUID(ctx) - if err != nil || machineID == "" { - // If dmidecode fails (permissions, not available, etc.), generate a random UUID - machineID = uuid.New().String() - log.Logger.Warnw("Failed to get hardware UUID, generated random agent ID", - "error", err, - "generated_id", machineID) - } else { - log.Logger.Infow("Initialized agent ID from hardware UUID", "machine_id", machineID) - } +type dbNodeUUIDState struct { + dbRW *sql.DB + dbRO *sql.DB +} - // Store the machine ID in database for persistence - if err := pkgmetadata.SetMetadata(ctx, dbRW, pkgmetadata.MetadataKeyMachineID, machineID); err != nil { - return "", fmt.Errorf("failed to store machine ID in database: %w", err) - } - log.Logger.Infow("Persisted agent ID to database", "machine_id", machineID) - } else { - log.Logger.Infow("Using persisted agent ID from database", "machine_id", machineID) +func (s dbNodeUUIDState) GetOrCreateNodeUUID(ctx context.Context, create func() (string, error)) (string, bool, error) { + if err := pkgmetadata.CreateTableMetadata(ctx, s.dbRW); err != nil { + return "", false, fmt.Errorf("create metadata table: %w", err) } + return agentstate.GetOrCreateNodeUUIDMetadata(ctx, s.dbRW, create) +} - return machineID, nil +// initializeMachineID retrieves or creates a machine ID +// This establishes the agent's stable identity for metrics reporting. +// Priority: DB (persisted) → DMI product UUID → random UUID +func initializeMachineID(ctx context.Context, dbRW, dbRO *sql.DB) (string, error) { + return nodeidentity.EnsureNodeUUID(ctx, dbNodeUUIDState{dbRW: dbRW, dbRO: dbRO}) } // getHealthCheckInterval determines the health check interval from config diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 010e4ff9..ef278e4c 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -382,35 +382,18 @@ func TestInitializeDatabasesSecuresFreshStateFile(t *testing.T) { // TestInitializeMachineID tests the initializeMachineID function. func TestInitializeMachineID(t *testing.T) { ctx := context.Background() - - // Initialize databases first (required for machine ID storage) - config := &config.Config{ - State: "", // Use in-memory database - } - dbRW, dbRO, err := initializeDatabases(ctx, config) + dbRW, dbRO, err := initializeDatabases(ctx, &config.Config{State: filepath.Join(t.TempDir(), "fleetint.state")}) require.NoError(t, err) defer dbRW.Close() defer dbRO.Close() - // Test initializeMachineID machineID, err := initializeMachineID(ctx, dbRW, dbRO) - - // The function should either succeed or fail gracefully - if err != nil { - // If it fails, it's likely due to missing pkgmetadata functionality - // which is acceptable in a unit test environment - t.Logf("initializeMachineID returned error (expected in test environment): %v", err) - return - } - - // If successful, verify the behavior + require.NoError(t, err) assert.NotEmpty(t, machineID) - // Verify machine ID can be read back machineID2, err := initializeMachineID(ctx, dbRW, dbRO) - if err == nil { - assert.Equal(t, machineID, machineID2, "Machine ID should be consistent across calls") - } + require.NoError(t, err) + assert.Equal(t, machineID, machineID2, "Machine ID should be consistent across calls") } // TestServerStop tests the Stop method.