package agentsync import ( "context" "log" "runtime" "sync" "sync/atomic" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/config" grpcclient "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/grpc" "gitea.hostxtra.co.uk/mrhid6/vantage/agent/internal/packages" "gitea.hostxtra.co.uk/mrhid6/vantage/shared/grpc/pb" ) // collectPackagesFlag is written by the 30s key poll and read by the hourly // package loop — two different goroutines, hence the atomic. // // It defaults to false, so an agent that has not yet completed a poll, or is // talking to a server too old to send the field, collects nothing. Off is the // safe default: collecting without a licence costs the customer storage they // are not paying for. var collectPackagesFlag atomic.Bool // firstPoll closes once a SyncKeys response has set the flag above. // // Without it the boot-time package report loses a race it can only lose: the // hourly loop starts before the first poll, reads a flag that is still false by // construction, and skips — so a freshly installed agent reports no packages for // an hour and the server shows nothing to scan. // How long the boot package report waits for that first poll. Two poll // intervals plus slack: long enough to cover one failed attempt, short enough // that a dead control plane does not hold the OS-update report hostage. const firstPollWait = 90 * time.Second var ( firstPoll = make(chan struct{}) firstPollOnce sync.Once ) func markFirstPoll() { firstPollOnce.Do(func() { close(firstPoll) }) } // waitFirstPoll blocks until the flag is known, or gives up. The wait is // bounded because this loop also reports OS updates, which do not depend on the // flag at all — a control plane that cannot be polled must not silence those too. func waitFirstPoll(ctx context.Context, limit time.Duration) { t := time.NewTimer(limit) defer t.Stop() select { case <-firstPoll: case <-t.C: log.Printf("package collection: no SyncKeys response within %s, collecting nothing this round", limit) case <-ctx.Done(): } } func collectPackagesEnabled() bool { return collectPackagesFlag.Load() } // reportPackages offers a hash of the installed package set and sends the full // list only if the server does not already hold it. // // It runs on the same hourly cadence as the update check because a package set // changes on roughly the same schedule, and reusing that loop means one timer // rather than two. func reportPackages(client *grpcclient.Client, cfg *config.Config) { if runtime.GOOS != "linux" { return } if !collectPackagesEnabled() { return } osrel, pkgs, err := packages.Collect() if err != nil { log.Printf("package collection error: %v", err) return } pbOS := pb.OSRelease{ Family: osrel.Family, VersionId: osrel.VersionID, Arch: osrel.Arch, } hash := packages.Hash(pkgs) // The offer: hash only, no body. On an unchanged host this is the whole // exchange, which is the point of the handshake. needFull, err := client.ReportPackages(&pb.ReportPackagesRequest{ ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, Hash: hash, Os: pbOS, }) if err != nil { log.Printf("ReportPackages offer error: %v", err) return } if !needFull { return } pbPkgs := make([]pb.InstalledPackage, len(pkgs)) for i, p := range pkgs { pbPkgs[i] = pb.InstalledPackage{ Name: p.Name, Version: p.Version, Epoch: int32(p.Epoch), Arch: p.Arch, SourceName: p.SourceName, } } if _, err := client.ReportPackages(&pb.ReportPackagesRequest{ ServerId: cfg.ServerID, AgentToken: cfg.AgentToken, Hash: hash, Os: pbOS, Packages: pbPkgs, }); err != nil { log.Printf("ReportPackages full error: %v", err) return } log.Printf("reported %d installed packages", len(pkgs)) }