Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
65ff0da
refactor: update CLI to use SDK domain types
retr0h Mar 4, 2026
b1274b4
docs: add agent fact collection system design
retr0h Mar 4, 2026
66eb090
docs: add agent facts implementation plan
retr0h Mar 4, 2026
1836de3
docs: update facts design and plan — providers, no plugins
retr0h Mar 4, 2026
295b38f
feat(job): add NetworkInterface and FactsRegistration types
retr0h Mar 4, 2026
fafbb84
feat(config): add NATSFacts and AgentFacts config types
retr0h Mar 4, 2026
2a2d7d3
feat(provider): extend host.Provider with fact methods
retr0h Mar 4, 2026
32ac20f
feat(provider): add netinfo.Provider for network interfaces
retr0h Mar 4, 2026
4e58b11
feat(nats): add facts KV bucket infrastructure
retr0h Mar 4, 2026
4e8271b
feat(provider): add IPv6 support to NetworkInterface
retr0h Mar 4, 2026
2b328c3
feat(agent): add facts writer with provider-based collection
retr0h Mar 4, 2026
daf0965
feat(provider): add IP family to NetworkInterface
retr0h Mar 4, 2026
6298313
feat(job): merge facts KV data into ListAgents and GetAgent
retr0h Mar 4, 2026
05732a1
feat(api): expose agent facts in AgentInfo responses
retr0h Mar 4, 2026
7fd994f
chore: add default facts config to osapi.yaml
retr0h Mar 4, 2026
c5a671f
docs: add facts configuration reference
retr0h Mar 4, 2026
f11fdb1
docs: update CLI docs and README with agent facts
retr0h Mar 4, 2026
4d4a586
docs: update feature and architecture pages with facts
retr0h Mar 4, 2026
d824d82
style: fix lint issues in facts implementation
retr0h Mar 4, 2026
0c0fd0a
test: achieve 100% coverage for netinfo and host providers
retr0h Mar 4, 2026
a637b18
feat(cli): display all agent facts in agent get output
retr0h Mar 5, 2026
1c23cbd
chore: pin osapi-sdk to latest and remove replace directive
retr0h Mar 5, 2026
79e146e
style: preallocate diskRows slice
retr0h Mar 5, 2026
4b59f88
test: achieve 100% coverage for buildAgentInfo
retr0h Mar 5, 2026
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ them to be used as appliances.
| 🖥️ **[Node Management][]** | Hostname, uptime, OS info, disk, memory, load |
| 🌐 **[Network Management][]** | DNS read/update, ping |
| ⚙️ **[Command Execution][]** | Remote exec and shell across managed hosts |
| 📊 **[System Facts][]** | Agent-collected system facts — architecture, kernel, FQDN, CPUs, network interfaces, service/package manager |
| ⚡ **[Async Job System][]** | NATS JetStream with KV-first architecture — broadcast, load-balanced, and label-based routing across hosts |
| 💚 **[Health][] & [Metrics][]** | Liveness, readiness, system status endpoints, Prometheus `/metrics` |
| 📋 **[Audit Logging][]** | Structured API audit trail in NATS KV with 30-day retention and admin-only read access |
Expand Down Expand Up @@ -76,4 +77,5 @@ them to be used as appliances.

The [MIT][] License.

[System Facts]: https://osapi-io.github.io/osapi/sidebar/features/node-management
[MIT]: LICENSE
4 changes: 3 additions & 1 deletion cmd/agent_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ func setupAgent(
b := connectNATSBundle(ctx, log, connCfg, kvBucket, namespace, streamName)

providerFactory := agent.NewProviderFactory(log)
hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, commandProvider := providerFactory.CreateProviders()
hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, netinfoProvider, commandProvider := providerFactory.CreateProviders()

a := agent.New(
appFs,
Expand All @@ -59,8 +59,10 @@ func setupAgent(
loadProvider,
dnsProvider,
pingProvider,
netinfoProvider,
commandProvider,
b.registryKV,
b.factsKV,
)

return a, b
Expand Down
17 changes: 15 additions & 2 deletions cmd/api_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ type natsBundle struct {
jobClient jobclient.JobClient
jobsKV jetstream.KeyValue
registryKV jetstream.KeyValue
factsKV jetstream.KeyValue
}

// setupAPIServer connects to NATS, creates the API server with all handlers,
Expand Down Expand Up @@ -111,7 +112,7 @@ func setupAPIServer(
checker := newHealthChecker(b.nc, b.jobsKV)
auditStore, auditKV, serverOpts := createAuditStore(ctx, log, b.nc, namespace)
metricsProvider := newMetricsProvider(
b.nc, b.jobsKV, b.registryKV, auditKV, streamName, b.jobClient,
b.nc, b.jobsKV, b.registryKV, b.factsKV, auditKV, streamName, b.jobClient,
)

sm := api.New(appConfig, log, serverOpts...)
Expand Down Expand Up @@ -153,10 +154,20 @@ func connectNATSBundle(
cli.LogFatal(log, "failed to create registry KV bucket", err)
}

var factsKV jetstream.KeyValue
if appConfig.NATS.Facts.Bucket != "" {
factsKVConfig := cli.BuildFactsKVConfig(namespace, appConfig.NATS.Facts)
factsKV, err = nc.CreateOrUpdateKVBucketWithConfig(ctx, factsKVConfig)
if err != nil {
cli.LogFatal(log, "failed to create facts KV bucket", err)
}
}

jc, err := jobclient.New(log, nc, &jobclient.Options{
Timeout: 30 * time.Second,
KVBucket: jobsKV,
RegistryKV: registryKV,
FactsKV: factsKV,
StreamName: streamName,
})
if err != nil {
Expand All @@ -168,6 +179,7 @@ func connectNATSBundle(
jobClient: jc,
jobsKV: jobsKV,
registryKV: registryKV,
factsKV: factsKV,
}
}

Expand Down Expand Up @@ -203,6 +215,7 @@ func newMetricsProvider(
nc messaging.NATSClient,
jobsKV jetstream.KeyValue,
registryKV jetstream.KeyValue,
factsKV jetstream.KeyValue,
auditKV jetstream.KeyValue,
streamName string,
jc jobclient.JobClient,
Expand Down Expand Up @@ -241,7 +254,7 @@ func newMetricsProvider(
}, nil
},
KVInfoFn: func(fnCtx context.Context) ([]health.KVMetrics, error) {
buckets := []jetstream.KeyValue{jobsKV, registryKV, auditKV}
buckets := []jetstream.KeyValue{jobsKV, registryKV, factsKV, auditKV}
results := make([]health.KVMetrics, 0, len(buckets))

for _, kv := range buckets {
Expand Down
95 changes: 60 additions & 35 deletions cmd/client_agent_get.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,10 @@ package cmd

import (
"fmt"
"net/http"
"strings"
"time"

"github.com/osapi-io/osapi-sdk/pkg/osapi/gen"
"github.com/osapi-io/osapi-sdk/pkg/osapi"
"github.com/spf13/cobra"

"github.com/retr0h/osapi/internal/cli"
Expand All @@ -43,65 +42,67 @@ var clientAgentGetCmd = &cobra.Command{

resp, err := sdkClient.Agent.Get(ctx, hostname)
if err != nil {
cli.LogFatal(logger, "failed to get agent details", err)
cli.HandleError(err, logger)
return
}

switch resp.StatusCode() {
case http.StatusOK:
if jsonOutput {
fmt.Println(string(resp.Body))
return
}

if resp.JSON200 == nil {
cli.LogFatal(logger, "failed response", fmt.Errorf("agent response was nil"))
}

displayAgentGetDetail(resp.JSON200)
case http.StatusUnauthorized:
cli.HandleAuthError(resp.JSON401, resp.StatusCode(), logger)
case http.StatusForbidden:
cli.HandleAuthError(resp.JSON403, resp.StatusCode(), logger)
case http.StatusNotFound:
cli.HandleUnknownError(resp.JSON404, resp.StatusCode(), logger)
default:
cli.HandleUnknownError(resp.JSON500, resp.StatusCode(), logger)
if jsonOutput {
fmt.Println(string(resp.RawJSON()))
return
}

displayAgentGetDetail(&resp.Data)
},
}

// displayAgentGetDetail renders detailed agent information in PrintKV style.
func displayAgentGetDetail(
data *gen.AgentInfo,
data *osapi.Agent,
) {
fmt.Println()

kvArgs := []string{"Hostname", data.Hostname, "Status", string(data.Status)}
kvArgs := []string{"Hostname", data.Hostname, "Status", data.Status}
cli.PrintKV(kvArgs...)

if data.Labels != nil && len(*data.Labels) > 0 {
if len(data.Labels) > 0 {
cli.PrintKV("Labels", cli.FormatLabels(data.Labels))
}

if data.OsInfo != nil {
cli.PrintKV("OS", data.OsInfo.Distribution+" "+cli.DimStyle.Render(data.OsInfo.Version))
if data.OSInfo != nil {
cli.PrintKV("OS", data.OSInfo.Distribution+" "+cli.DimStyle.Render(data.OSInfo.Version))
}

if data.Uptime != "" {
cli.PrintKV("Uptime", data.Uptime)
}

if !data.StartedAt.IsZero() {
cli.PrintKV("Age", cli.FormatAge(time.Since(data.StartedAt)))
}

if data.Uptime != nil {
cli.PrintKV("Uptime", *data.Uptime)
if !data.RegisteredAt.IsZero() {
cli.PrintKV("Last Seen", cli.FormatAge(time.Since(data.RegisteredAt))+" ago")
}

if data.StartedAt != nil {
cli.PrintKV("Age", cli.FormatAge(time.Since(*data.StartedAt)))
if data.Architecture != "" {
cli.PrintKV("Arch", data.Architecture)
}

if data.RegisteredAt != nil {
cli.PrintKV("Last Seen", cli.FormatAge(time.Since(*data.RegisteredAt))+" ago")
if data.KernelVersion != "" {
cli.PrintKV("Kernel", data.KernelVersion)
}

if data.CPUCount > 0 {
cli.PrintKV("CPUs", fmt.Sprintf("%d", data.CPUCount))
}

if data.Fqdn != "" {
cli.PrintKV("FQDN", data.Fqdn)
}

if data.LoadAverage != nil {
cli.PrintKV("Load", fmt.Sprintf("%.2f, %.2f, %.2f",
data.LoadAverage.N1min, data.LoadAverage.N5min, data.LoadAverage.N15min,
data.LoadAverage.OneMin, data.LoadAverage.FiveMin, data.LoadAverage.FifteenMin,
)+" "+cli.DimStyle.Render("(1m, 5m, 15m)"))
}

Expand All @@ -113,6 +114,30 @@ func displayAgentGetDetail(
memParts = append(memParts, cli.FormatBytes(data.Memory.Free)+" free")
cli.PrintKV("Memory", strings.Join(memParts, ", "))
}

if data.ServiceMgr != "" {
cli.PrintKV("Service Mgr", data.ServiceMgr)
}

if data.PackageMgr != "" {
cli.PrintKV("Package Mgr", data.PackageMgr)
}

if len(data.Interfaces) > 0 {
for _, iface := range data.Interfaces {
parts := []string{}
if iface.IPv4 != "" {
parts = append(parts, iface.IPv4)
}
if iface.IPv6 != "" {
parts = append(parts, iface.IPv6)
}
if iface.MAC != "" {
parts = append(parts, cli.DimStyle.Render(iface.MAC))
}
cli.PrintKV("Interface "+iface.Name, strings.Join(parts, " "))
}
}
}

func init() {
Expand Down
93 changes: 40 additions & 53 deletions cmd/client_agent_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ package cmd

import (
"fmt"
"net/http"
"time"

"github.com/spf13/cobra"
Expand All @@ -41,66 +40,54 @@ Shows each agent's hostname, status, labels, age, load, and OS.`,

resp, err := sdkClient.Agent.List(ctx)
if err != nil {
cli.LogFatal(logger, "failed to list agents", err)
cli.HandleError(err, logger)
return
}

switch resp.StatusCode() {
case http.StatusOK:
if jsonOutput {
fmt.Println(string(resp.Body))
return
}
if jsonOutput {
fmt.Println(string(resp.RawJSON()))
return
}

if resp.JSON200 == nil {
cli.LogFatal(logger, "failed response", fmt.Errorf("agents response was nil"))
}
agents := resp.Data.Agents
if len(agents) == 0 {
fmt.Println("No active agents found.")
return
}

agents := resp.JSON200.Agents
if len(agents) == 0 {
fmt.Println("No active agents found.")
return
rows := make([][]string, 0, len(agents))
for _, a := range agents {
labels := cli.FormatLabels(a.Labels)
age := ""
if !a.StartedAt.IsZero() {
age = cli.FormatAge(time.Since(a.StartedAt))
}

rows := make([][]string, 0, len(agents))
for _, a := range agents {
labels := cli.FormatLabels(a.Labels)
age := ""
if a.StartedAt != nil {
age = cli.FormatAge(time.Since(*a.StartedAt))
}
loadStr := ""
if a.LoadAverage != nil {
loadStr = fmt.Sprintf("%.2f", a.LoadAverage.N1min)
}
osStr := ""
if a.OsInfo != nil {
osStr = a.OsInfo.Distribution + " " + a.OsInfo.Version
}
rows = append(rows, []string{
a.Hostname,
string(a.Status),
labels,
age,
loadStr,
osStr,
})
loadStr := ""
if a.LoadAverage != nil {
loadStr = fmt.Sprintf("%.2f", a.LoadAverage.OneMin)
}

sections := []cli.Section{
{
Title: fmt.Sprintf("Active Agents (%d)", resp.JSON200.Total),
Headers: []string{"HOSTNAME", "STATUS", "LABELS", "AGE", "LOAD (1m)", "OS"},
Rows: rows,
},
osStr := ""
if a.OSInfo != nil {
osStr = a.OSInfo.Distribution + " " + a.OSInfo.Version
}
cli.PrintCompactTable(sections)
case http.StatusUnauthorized:
cli.HandleAuthError(resp.JSON401, resp.StatusCode(), logger)
case http.StatusForbidden:
cli.HandleAuthError(resp.JSON403, resp.StatusCode(), logger)
default:
cli.HandleUnknownError(resp.JSON500, resp.StatusCode(), logger)
rows = append(rows, []string{
a.Hostname,
a.Status,
labels,
age,
loadStr,
osStr,
})
}

sections := []cli.Section{
{
Title: fmt.Sprintf("Active Agents (%d)", resp.Data.Total),
Headers: []string{"HOSTNAME", "STATUS", "LABELS", "AGE", "LOAD (1m)", "OS"},
Rows: rows,
},
}
cli.PrintCompactTable(sections)
},
}

Expand Down
Loading