Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/driver-fleet-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": minor
---

Report a bounded driver inventory to Nova when federation is enabled. Reports include loaded code hashes and package provenance, but no site config, device IDs, endpoints or credentials.
17 changes: 15 additions & 2 deletions docs/nova-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,21 @@ FTW keeps its clean snake_case site convention internally. The default
vocabulary and battery sign at this one boundary. `schema_mode: unified`
publishes the clean schema when the target Nova deployment supports it.

Only DER telemetry and registered device identity are published. Operator
credentials and FTW configuration are not telemetry payloads.
Only DER telemetry, registered device identity and the bounded driver inventory
are published. Operator credentials and FTW configuration are not payloads.

## Driver inventory

When Nova federation is enabled, FTW also publishes
`sourceful.driver-inventory/v1` on connect, after a loaded-driver change and at
least every 15 minutes. It reports driver ID, version, loaded source or package
hash, package channel, declared control class, instance counts and health.

The report does not contain instance or site names, driver config, connection
details, device IDs, tokens, logs, command inputs or vendor responses. Nova
uses the authenticated MQTT identity for gateway and organization ownership.
Fleet reports must show how many FTW gateways sent a fresh inventory; the first
beta counts do not cover FTW sites that have not enabled Nova federation.

## Claim and provision

Expand Down
58 changes: 57 additions & 1 deletion go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/srcfl/ftw/go/internal/control"
"github.com/srcfl/ftw/go/internal/currency"
"github.com/srcfl/ftw/go/internal/devtools"
"github.com/srcfl/ftw/go/internal/driverinventory"
"github.com/srcfl/ftw/go/internal/driverrepo"
"github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/events"
Expand Down Expand Up @@ -2063,7 +2064,23 @@ func main() {
siteIdentity, err := nova.LoadOrCreateIdentity(identityKeyPath)
if err != nil {
slog.Warn("nova federation disabled — gateway identity unavailable", "err", err, "path", identityKeyPath)
} else if pub, err := nova.Start(cfg.Nova, siteIdentity, st, tel); err != nil {
} else if pub, err := nova.Start(cfg.Nova, siteIdentity, st, tel,
nova.WithDriverInventory(func(now time.Time) (driverinventory.Snapshot, error) {
cfgMu.RLock()
driverCfg := append([]config.Driver(nil), cfg.Drivers...)
cfgMu.RUnlock()
return driverinventory.Build(now, driverinventory.Input{
HostVersion: Version,
Drivers: driverCfg,
RunningNames: reg.Names(),
Health: tel.AllHealth(),
UserDriverDir: *userDriversDirFlag,
ManagedDriverDir: driverRepository.ActiveDir(),
BundledDriverDir: resolveDriverDir(),
RepositoryDrivers: inventoryRepositoryArtifacts(driverRepository),
})
}),
); err != nil {
slog.Warn("nova publisher failed to start", "err", err)
} else if pub != nil {
defer pub.Stop()
Expand Down Expand Up @@ -2539,6 +2556,45 @@ func main() {
}
}

func inventoryRepositoryArtifacts(manager *driverrepo.Manager) []driverinventory.RepositoryArtifact {
if manager == nil {
return nil
}
active := manager.Status().Active
out := make([]driverinventory.RepositoryArtifact, 0, len(active))
for _, installed := range active {
item := driverinventory.RepositoryArtifact{
LogicalPath: installed.LogicalPath,
InstalledPath: installed.InstalledPath,
DriverID: installed.DriverID,
Version: installed.Version,
SHA256: installed.SHA256,
RepositoryID: installed.RepoID,
}
versions, err := manager.AvailableVersions(installed.DriverID)
if err == nil {
for _, candidate := range versions {
driver := candidate.Driver
if candidate.RepositoryID != installed.RepoID || driver.Version != installed.Version || !strings.EqualFold(driver.SHA256, installed.SHA256) {
continue
}
item.PackageID = driver.PackageID
item.PackageChannel = driver.Channel
if driver.PackageID != "" {
if driver.Metadata.ReadOnly {
item.ControlClass = "read_only"
} else {
item.ControlClass = "control"
}
}
break
}
}
out = append(out, item)
}
return out
}

// snapshotLoop writes a recovery snapshot of state.db daily and once on
// shutdown. The snapshot is the restore source if state.db corrupts (see
// state.openChecked). cache.db needs none — it's re-fetchable. Daily (not
Expand Down
Loading