From 65ff0da9dbe5a707794c6212a9d0ca20717dfdda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 4 Mar 2026 09:57:12 -0800 Subject: [PATCH 01/24] refactor: update CLI to use SDK domain types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all gen type usage in CLI commands with SDK domain types. HandleError uses errors.As() for typed error handling. All 22 cmd files, audit export package, and CLI UI helpers updated. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_agent_get.go | 55 ++-- cmd/client_agent_list.go | 93 +++--- cmd/client_audit_export.go | 23 +- cmd/client_audit_get.go | 64 ++--- cmd/client_audit_list.go | 98 +++---- cmd/client_health.go | 26 +- cmd/client_health_ready.go | 43 +-- cmd/client_health_status.go | 114 +++----- cmd/client_job_add.go | 47 ++- cmd/client_job_delete.go | 47 ++- cmd/client_job_get.go | 31 +- cmd/client_job_list.go | 63 +--- cmd/client_job_retry.go | 49 ++-- cmd/client_job_run.go | 32 +-- cmd/client_job_status.go | 52 +--- cmd/client_node_command_exec.go | 120 ++++---- cmd/client_node_command_shell.go | 99 +++---- cmd/client_node_hostname_get.go | 60 ++-- cmd/client_node_network_dns_get.go | 73 ++--- cmd/client_node_network_dns_update.go | 75 +++-- cmd/client_node_network_ping.go | 91 +++--- cmd/client_node_status_get.go | 83 +++--- go.mod | 6 +- go.sum | 10 +- internal/audit/export/export_public_test.go | 41 ++- internal/audit/export/file.go | 4 +- internal/audit/export/file_public_test.go | 21 +- internal/audit/export/file_test.go | 9 +- internal/audit/export/types.go | 6 +- internal/cli/ui.go | 149 ++++------ internal/cli/ui_public_test.go | 300 ++++++-------------- 31 files changed, 720 insertions(+), 1264 deletions(-) diff --git a/cmd/client_agent_get.go b/cmd/client_agent_get.go index 62e413e9..7f136064 100644 --- a/cmd/client_agent_get.go +++ b/cmd/client_agent_get.go @@ -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" @@ -43,65 +42,51 @@ 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 != nil { - cli.PrintKV("Uptime", *data.Uptime) + if data.Uptime != "" { + cli.PrintKV("Uptime", data.Uptime) } - if data.StartedAt != nil { - cli.PrintKV("Age", cli.FormatAge(time.Since(*data.StartedAt))) + if !data.StartedAt.IsZero() { + cli.PrintKV("Age", cli.FormatAge(time.Since(data.StartedAt))) } - if data.RegisteredAt != nil { - cli.PrintKV("Last Seen", cli.FormatAge(time.Since(*data.RegisteredAt))+" ago") + if !data.RegisteredAt.IsZero() { + cli.PrintKV("Last Seen", cli.FormatAge(time.Since(data.RegisteredAt))+" ago") } 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)")) } diff --git a/cmd/client_agent_list.go b/cmd/client_agent_list.go index 3491060a..89ac4ccc 100644 --- a/cmd/client_agent_list.go +++ b/cmd/client_agent_list.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "time" "github.com/spf13/cobra" @@ -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) }, } diff --git a/cmd/client_audit_export.go b/cmd/client_audit_export.go index 19f483ee..b4389cb9 100644 --- a/cmd/client_audit_export.go +++ b/cmd/client_audit_export.go @@ -23,10 +23,9 @@ package cmd import ( "context" "fmt" - "net/http" "strconv" - "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/audit/export" @@ -51,29 +50,17 @@ entry as a JSON line (JSONL format). Requires audit:read permission. ctx := cmd.Context() resp, err := sdkClient.Audit.Export(ctx) if err != nil { - cli.LogFatal(logger, "API request failed", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusOK: - if resp.JSON200 == nil { - cli.LogFatal(logger, "export failed", fmt.Errorf("response was nil")) - } - - writeExport(ctx, resp.JSON200.Items, resp.JSON200.TotalItems) - 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) - } + writeExport(ctx, resp.Data.Items, resp.Data.TotalItems) }, } func writeExport( ctx context.Context, - items []gen.AuditEntry, + items []osapi.AuditEntry, totalItems int, ) { var exporter export.Exporter diff --git a/cmd/client_audit_get.go b/cmd/client_audit_get.go index 2fad54fd..ebdc240c 100644 --- a/cmd/client_audit_get.go +++ b/cmd/client_audit_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "strconv" "strings" @@ -45,47 +44,32 @@ Requires audit:read permission. resp, err := sdkClient.Audit.Get(ctx, auditID) if err != nil { - cli.LogFatal(logger, "failed to get audit log entry", 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("audit entry response was nil")) - } - - entry := resp.JSON200.Entry - - fmt.Println() - cli.PrintKV("ID", entry.Id.String()) - cli.PrintKV("Timestamp", entry.Timestamp.Format("2006-01-02 15:04:05")) - cli.PrintKV("User", entry.User) - cli.PrintKV("Roles", strings.Join(entry.Roles, ", ")) - cli.PrintKV("Method", entry.Method, "Path", entry.Path) - cli.PrintKV( - "Status", - strconv.Itoa(entry.ResponseCode), - "Duration", - strconv.FormatInt(entry.DurationMs, 10)+"ms", - ) - cli.PrintKV("Source IP", entry.SourceIp) - if entry.OperationId != nil { - cli.PrintKV("Operation", *entry.OperationId) - } - - 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 + } + + entry := &resp.Data + + fmt.Println() + cli.PrintKV("ID", entry.ID) + cli.PrintKV("Timestamp", entry.Timestamp.Format("2006-01-02 15:04:05")) + cli.PrintKV("User", entry.User) + cli.PrintKV("Roles", strings.Join(entry.Roles, ", ")) + cli.PrintKV("Method", entry.Method, "Path", entry.Path) + cli.PrintKV( + "Status", + strconv.Itoa(entry.ResponseCode), + "Duration", + strconv.FormatInt(entry.DurationMs, 10)+"ms", + ) + cli.PrintKV("Source IP", entry.SourceIP) + if entry.OperationID != "" { + cli.PrintKV("Operation", entry.OperationID) } }, } diff --git a/cmd/client_audit_list.go b/cmd/client_audit_list.go index 09322125..9d1dd77a 100644 --- a/cmd/client_audit_list.go +++ b/cmd/client_audit_list.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "strconv" "github.com/spf13/cobra" @@ -48,66 +47,51 @@ response status, and duration. Requires audit:read permission. ctx := cmd.Context() resp, err := sdkClient.Audit.List(ctx, auditListLimit, auditListOffset) if err != nil { - cli.LogFatal(logger, "failed to get audit logs", 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("audit list response was nil")) - } - - fmt.Println() - cli.PrintKV("Total", strconv.Itoa(resp.JSON200.TotalItems)) - - if len(resp.JSON200.Items) == 0 { - fmt.Println(" No audit entries found.") - return - } - - rows := make([][]string, 0, len(resp.JSON200.Items)) - for _, entry := range resp.JSON200.Items { - rows = append(rows, []string{ - entry.Id.String(), - entry.Timestamp.Format("2006-01-02 15:04:05"), - entry.User, - entry.Method, - entry.Path, - strconv.Itoa(entry.ResponseCode), - strconv.FormatInt(entry.DurationMs, 10) + "ms", - }) - } - - cli.PrintCompactTable([]cli.Section{ - { - Title: "Audit Entries", - Headers: []string{ - "ID", - "TIMESTAMP", - "USER", - "METHOD", - "PATH", - "STATUS", - "DURATION", - }, - Rows: rows, - }, - }) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + fmt.Println() + cli.PrintKV("Total", strconv.Itoa(resp.Data.TotalItems)) + + if len(resp.Data.Items) == 0 { + fmt.Println(" No audit entries found.") + return } + + rows := make([][]string, 0, len(resp.Data.Items)) + for _, entry := range resp.Data.Items { + rows = append(rows, []string{ + entry.ID, + entry.Timestamp.Format("2006-01-02 15:04:05"), + entry.User, + entry.Method, + entry.Path, + strconv.Itoa(entry.ResponseCode), + strconv.FormatInt(entry.DurationMs, 10) + "ms", + }) + } + + cli.PrintCompactTable([]cli.Section{ + { + Title: "Audit Entries", + Headers: []string{ + "ID", + "TIMESTAMP", + "USER", + "METHOD", + "PATH", + "STATUS", + "DURATION", + }, + Rows: rows, + }, + }) }, } diff --git a/cmd/client_health.go b/cmd/client_health.go index fdbd2084..ecb59492 100644 --- a/cmd/client_health.go +++ b/cmd/client_health.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "github.com/spf13/cobra" @@ -41,26 +40,17 @@ Running without a subcommand performs a liveness probe. ctx := cmd.Context() resp, err := sdkClient.Health.Liveness(ctx) if err != nil { - cli.LogFatal(logger, "failed to get health endpoint", 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("health response was nil")) - } - - fmt.Println() - cli.PrintKV("Status", resp.JSON200.Status) - - default: - cli.HandleUnknownError(nil, resp.StatusCode(), logger) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + fmt.Println() + cli.PrintKV("Status", resp.Data.Status) }, } diff --git a/cmd/client_health_ready.go b/cmd/client_health_ready.go index c4db292b..bd1df5c8 100644 --- a/cmd/client_health_ready.go +++ b/cmd/client_health_ready.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "github.com/spf13/cobra" @@ -39,41 +38,19 @@ var clientHealthReadyCmd = &cobra.Command{ ctx := cmd.Context() resp, err := sdkClient.Health.Ready(ctx) if err != nil { - cli.LogFatal(logger, "failed to get health ready endpoint", 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("health ready response was nil")) - } - - fmt.Println() - cli.PrintKV("Status", resp.JSON200.Status) - - case http.StatusServiceUnavailable: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } - - if resp.JSON503 == nil { - cli.LogFatal(logger, "failed response", fmt.Errorf("health ready response was nil")) - } - - fmt.Println() - cli.PrintKV("Status", resp.JSON503.Status) - if resp.JSON503.Error != nil { - cli.PrintKV("Error", *resp.JSON503.Error) - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - default: - cli.HandleUnknownError(nil, resp.StatusCode(), logger) + fmt.Println() + cli.PrintKV("Status", resp.Data.Status) + if resp.Data.Error != "" { + cli.PrintKV("Error", resp.Data.Error) } }, } diff --git a/cmd/client_health_status.go b/cmd/client_health_status.go index 2ad30799..fa1702f3 100644 --- a/cmd/client_health_status.go +++ b/cmd/client_health_status.go @@ -22,9 +22,8 @@ package cmd import ( "fmt" - "net/http" - "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" @@ -41,71 +40,38 @@ Requires authentication. ctx := cmd.Context() resp, err := sdkClient.Health.Status(ctx) if err != nil { - cli.LogFatal(logger, "failed to get health status endpoint", 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("health status response was nil"), - ) - } - - displayStatusHealth(resp.JSON200) - - case http.StatusServiceUnavailable: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } - - if resp.JSON503 == nil { - cli.LogFatal( - logger, - "failed response", - fmt.Errorf("health status response was nil"), - ) - } - - displayStatusHealth(resp.JSON503) - - case http.StatusUnauthorized: - cli.HandleAuthError(resp.JSON401, resp.StatusCode(), logger) - case http.StatusForbidden: - cli.HandleAuthError(resp.JSON403, resp.StatusCode(), logger) - default: - cli.HandleUnknownError(nil, resp.StatusCode(), logger) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + displayStatusHealth(&resp.Data) }, } // displayStatusHealth renders health status output with system metrics. func displayStatusHealth( - data *gen.StatusResponse, + data *osapi.SystemStatus, ) { fmt.Println() cli.PrintKV("Status", data.Status, "Version", data.Version, "Uptime", data.Uptime) // NATS connection info (merged with component health) - if data.Nats != nil { + if data.NATS != nil { natsStatus := "ok" if c, ok := data.Components["nats"]; ok && c.Status != "ok" { natsStatus = c.Status - if c.Error != nil { - natsStatus += " " + cli.DimStyle.Render(*c.Error) + if c.Error != "" { + natsStatus += " " + cli.DimStyle.Render(c.Error) } } - natsVal := natsStatus + " " + cli.DimStyle.Render(data.Nats.Url) - if data.Nats.Version != "" { - natsVal += " " + cli.DimStyle.Render("(v"+data.Nats.Version+")") + natsVal := natsStatus + " " + cli.DimStyle.Render(data.NATS.URL) + if data.NATS.Version != "" { + natsVal += " " + cli.DimStyle.Render("(v"+data.NATS.Version+")") } cli.PrintKV("NATS", natsVal) } @@ -113,8 +79,8 @@ func displayStatusHealth( // KV component (without duplicating the NATS line) if c, ok := data.Components["kv"]; ok { kvVal := c.Status - if c.Error != nil { - kvVal += " " + cli.DimStyle.Render(*c.Error) + if c.Error != "" { + kvVal += " " + cli.DimStyle.Render(c.Error) } cli.PrintKV("KV", kvVal) } @@ -125,8 +91,8 @@ func displayStatusHealth( continue } val := component.Status - if component.Error != nil { - val += " " + cli.DimStyle.Render(*component.Error) + if component.Error != "" { + val += " " + cli.DimStyle.Render(component.Error) } cli.PrintKV(name, val) } @@ -136,14 +102,10 @@ func displayStatusHealth( "%d total, %d ready", data.Agents.Total, data.Agents.Ready, )) - if data.Agents.Agents != nil { - rows := make([][]string, 0, len(*data.Agents.Agents)) - for _, a := range *data.Agents.Agents { - labels := "" - if a.Labels != nil { - labels = *a.Labels - } - rows = append(rows, []string{a.Hostname, labels, a.Registered}) + if len(data.Agents.Agents) > 0 { + rows := make([][]string, 0, len(data.Agents.Agents)) + for _, a := range data.Agents.Agents { + rows = append(rows, []string{a.Hostname, a.Labels, a.Registered}) } cli.PrintCompactTable([]cli.Section{{ Headers: []string{"HOSTNAME", "LABELS", "REGISTERED"}, @@ -166,32 +128,28 @@ func displayStatusHealth( } // Streams - if data.Streams != nil { - for _, s := range *data.Streams { - cli.PrintKV("Stream", fmt.Sprintf( - "%s "+cli.DimStyle.Render("(%d msgs, %s, %d consumers)"), - s.Name, s.Messages, cli.FormatBytes(s.Bytes), s.Consumers, - )) - } + for _, s := range data.Streams { + cli.PrintKV("Stream", fmt.Sprintf( + "%s "+cli.DimStyle.Render("(%d msgs, %s, %d consumers)"), + s.Name, s.Messages, cli.FormatBytes(s.Bytes), s.Consumers, + )) } // KV Buckets - if data.KvBuckets != nil { - for _, b := range *data.KvBuckets { - cli.PrintKV("Bucket", fmt.Sprintf( - "%s "+cli.DimStyle.Render("(%d keys, %s)"), - b.Name, b.Keys, cli.FormatBytes(b.Bytes), - )) - } + for _, b := range data.KVBuckets { + cli.PrintKV("Bucket", fmt.Sprintf( + "%s "+cli.DimStyle.Render("(%d keys, %s)"), + b.Name, b.Keys, cli.FormatBytes(b.Bytes), + )) } // Consumers last — the table can be long with many agents if data.Consumers != nil { fmt.Println() cli.PrintKV("Consumers", fmt.Sprintf("%d total", data.Consumers.Total)) - if data.Consumers.Consumers != nil { - rows := make([][]string, 0, len(*data.Consumers.Consumers)) - for _, c := range *data.Consumers.Consumers { + if len(data.Consumers.Consumers) > 0 { + rows := make([][]string, 0, len(data.Consumers.Consumers)) + for _, c := range data.Consumers.Consumers { rows = append(rows, []string{ c.Name, fmt.Sprintf("%d", c.Pending), diff --git a/cmd/client_job_add.go b/cmd/client_job_add.go index 8b3d7245..75544b26 100644 --- a/cmd/client_job_add.go +++ b/cmd/client_job_add.go @@ -25,7 +25,6 @@ import ( "fmt" "io" "log/slog" - "net/http" "os" "github.com/spf13/cobra" @@ -62,39 +61,25 @@ var clientJobAddCmd = &cobra.Command{ resp, err := sdkClient.Job.Create(ctx, operationData, targetHostname) if err != nil { - cli.LogFatal(logger, "failed to create job", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusCreated: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } - - if resp.JSON201 == nil { - cli.LogFatal(logger, "failed response", fmt.Errorf("create job response was nil")) - } - - fmt.Println() - cli.PrintKV("Job ID", resp.JSON201.JobId.String(), "Status", resp.JSON201.Status) - if resp.JSON201.Revision != nil { - cli.PrintKV("Revision", fmt.Sprintf("%d", *resp.JSON201.Revision)) - } - - logger.Info("job created successfully", - slog.String("job_id", resp.JSON201.JobId.String()), - slog.String("target_hostname", targetHostname), - ) - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID, "Status", resp.Data.Status) + if resp.Data.Revision != 0 { + cli.PrintKV("Revision", fmt.Sprintf("%d", resp.Data.Revision)) + } + + logger.Info("job created successfully", + slog.String("job_id", resp.Data.JobID), + slog.String("target_hostname", targetHostname), + ) }, } diff --git a/cmd/client_job_delete.go b/cmd/client_job_delete.go index 15905fb6..dcf80895 100644 --- a/cmd/client_job_delete.go +++ b/cmd/client_job_delete.go @@ -24,7 +24,6 @@ import ( "encoding/json" "fmt" "log/slog" - "net/http" "time" "github.com/spf13/cobra" @@ -41,41 +40,29 @@ var clientJobDeleteCmd = &cobra.Command{ jobID, _ := cmd.Flags().GetString("job-id") ctx := cmd.Context() - resp, err := sdkClient.Job.Delete(ctx, jobID) + err := sdkClient.Job.Delete(ctx, jobID) if err != nil { - cli.LogFatal(logger, "failed to delete job", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusNoContent: - if jsonOutput { - result := map[string]interface{}{ - "status": "deleted", - "job_id": jobID, - "timestamp": time.Now().Format(time.RFC3339), - } - resultJSON, _ := json.Marshal(result) - fmt.Println(string(resultJSON)) - return + if jsonOutput { + result := map[string]interface{}{ + "status": "deleted", + "job_id": jobID, + "timestamp": time.Now().Format(time.RFC3339), } + resultJSON, _ := json.Marshal(result) + fmt.Println(string(resultJSON)) + return + } - fmt.Println() - cli.PrintKV("Job ID", jobID, "Status", "Deleted") + fmt.Println() + cli.PrintKV("Job ID", jobID, "Status", "Deleted") - logger.Info("job deleted successfully", - slog.String("job_id", jobID), - ) - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - case http.StatusNotFound: - cli.HandleUnknownError(resp.JSON404, resp.StatusCode(), logger) - 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) - } + logger.Info("job deleted successfully", + slog.String("job_id", jobID), + ) }, } diff --git a/cmd/client_job_get.go b/cmd/client_job_get.go index aca4af61..efa011a3 100644 --- a/cmd/client_job_get.go +++ b/cmd/client_job_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "github.com/spf13/cobra" @@ -40,32 +39,16 @@ var clientJobGetCmd = &cobra.Command{ resp, err := sdkClient.Job.Get(ctx, jobID) if err != nil { - cli.LogFatal(logger, "failed to get job", 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("job detail response was nil")) - } - - cli.DisplayJobDetailResponse(resp.JSON200) - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - case http.StatusNotFound: - cli.HandleUnknownError(resp.JSON404, resp.StatusCode(), logger) - 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) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + cli.DisplayJobDetail(&resp.Data) }, } diff --git a/cmd/client_job_list.go b/cmd/client_job_list.go index dacf9318..abf9d105 100644 --- a/cmd/client_job_list.go +++ b/cmd/client_job_list.go @@ -23,12 +23,10 @@ package cmd import ( "encoding/json" "fmt" - "net/http" "strings" "time" "github.com/osapi-io/osapi-sdk/pkg/osapi" - "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" @@ -52,46 +50,20 @@ var clientJobListCmd = &cobra.Command{ Offset: offsetFlag, }) if err != nil { - cli.LogFatal(logger, "failed to list jobs", err) - } - - if jobsResp.StatusCode() != http.StatusOK { - switch jobsResp.StatusCode() { - case http.StatusBadRequest: - cli.HandleUnknownError(jobsResp.JSON400, jobsResp.StatusCode(), logger) - case http.StatusUnauthorized: - cli.HandleAuthError(jobsResp.JSON401, jobsResp.StatusCode(), logger) - case http.StatusForbidden: - cli.HandleAuthError(jobsResp.JSON403, jobsResp.StatusCode(), logger) - default: - cli.HandleUnknownError(jobsResp.JSON500, jobsResp.StatusCode(), logger) - } + cli.HandleError(err, logger) return } // Get queue stats for summary statsResp, err := sdkClient.Job.QueueStats(ctx) if err != nil { - cli.LogFatal(logger, "failed to get queue stats", err) - } - - if statsResp.StatusCode() != http.StatusOK { - cli.HandleUnknownError(statsResp.JSON500, statsResp.StatusCode(), logger) + cli.HandleError(err, logger) return } - // Extract jobs from response (already paginated server-side) - var jobs []gen.JobDetailResponse - if jobsResp.JSON200 != nil && jobsResp.JSON200.Items != nil { - jobs = *jobsResp.JSON200.Items - } - - totalItems := 0 - if jobsResp.JSON200 != nil && jobsResp.JSON200.TotalItems != nil { - totalItems = *jobsResp.JSON200.TotalItems - } - - statusCounts := extractStatusCounts(statsResp.JSON200) + jobs := jobsResp.Data.Items + totalItems := jobsResp.Data.TotalItems + statusCounts := statsResp.Data.StatusCounts if jsonOutput { displayJobListJSON(jobs, totalItems, statusCounts, statusFilter, limitFlag, offsetFlag) @@ -110,17 +82,8 @@ var clientJobListCmd = &cobra.Command{ }, } -func extractStatusCounts( - stats *gen.QueueStatsResponse, -) map[string]int { - if stats != nil && stats.StatusCounts != nil { - return *stats.StatusCounts - } - return map[string]int{} -} - func displayJobListJSON( - jobs []gen.JobDetailResponse, + jobs []osapi.JobDetail, totalItems int, statusCounts map[string]int, statusFilter string, @@ -173,7 +136,7 @@ func displayJobListSummary( } func displayJobListTable( - jobs []gen.JobDetailResponse, + jobs []osapi.JobDetail, ) { if len(jobs) == 0 { return @@ -181,25 +144,23 @@ func displayJobListTable( jobRows := [][]string{} for _, j := range jobs { - created := cli.SafeString(j.Created) + created := j.Created if t, err := time.Parse(time.RFC3339, created); err == nil { created = t.Format("2006-01-02 15:04") } operationSummary := "Unknown" if j.Operation != nil { - if operationType, ok := (*j.Operation)["type"].(string); ok { + if operationType, ok := j.Operation["type"].(string); ok { operationSummary = operationType } } - target := cli.SafeString(j.Hostname) - jobRows = append(jobRows, []string{ - cli.SafeUUID(j.Id), - cli.SafeString(j.Status), + j.ID, + j.Status, created, - target, + j.Hostname, operationSummary, }) } diff --git a/cmd/client_job_retry.go b/cmd/client_job_retry.go index 4ae6650b..0ae0e08b 100644 --- a/cmd/client_job_retry.go +++ b/cmd/client_job_retry.go @@ -23,7 +23,6 @@ package cmd import ( "fmt" "log/slog" - "net/http" "github.com/spf13/cobra" @@ -42,42 +41,26 @@ var clientJobRetryCmd = &cobra.Command{ resp, err := sdkClient.Job.Retry(ctx, jobID, targetHostname) if err != nil { - cli.LogFatal(logger, "failed to retry job", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusCreated: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } - - if resp.JSON201 == nil { - cli.LogFatal(logger, "failed response", fmt.Errorf("retry job response was nil")) - } - - fmt.Println() - cli.PrintKV("Job ID", resp.JSON201.JobId.String(), "Status", resp.JSON201.Status) - if resp.JSON201.Revision != nil { - cli.PrintKV("Revision", fmt.Sprintf("%d", *resp.JSON201.Revision)) - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - logger.Info("job retried successfully", - slog.String("original_job_id", jobID), - slog.String("new_job_id", resp.JSON201.JobId.String()), - slog.String("target_hostname", targetHostname), - ) - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - case http.StatusNotFound: - cli.HandleUnknownError(resp.JSON404, resp.StatusCode(), logger) - 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) + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID, "Status", resp.Data.Status) + if resp.Data.Revision != 0 { + cli.PrintKV("Revision", fmt.Sprintf("%d", resp.Data.Revision)) } + + logger.Info("job retried successfully", + slog.String("original_job_id", jobID), + slog.String("new_job_id", resp.Data.JobID), + slog.String("target_hostname", targetHostname), + ) }, } diff --git a/cmd/client_job_run.go b/cmd/client_job_run.go index 6cf04d87..b4650a8d 100644 --- a/cmd/client_job_run.go +++ b/cmd/client_job_run.go @@ -26,7 +26,6 @@ import ( "fmt" "io" "log/slog" - "net/http" "os" "time" @@ -74,24 +73,11 @@ This combines job submission and retrieval into a single command for convenience // Submit the job resp, err := sdkClient.Job.Create(ctx, operationData, targetHostname) if err != nil { - logger.Error("failed to submit job", slog.String("error", err.Error())) + cli.HandleError(err, logger) return } - if resp.StatusCode() != http.StatusCreated { - logger.Error("failed to submit job", - slog.Int("status_code", resp.StatusCode()), - slog.String("body", string(resp.Body)), - ) - return - } - - if resp.JSON201 == nil { - logger.Error("failed to submit job: nil response") - return - } - - jobID := resp.JSON201.JobId.String() + jobID := resp.Data.JobID logger.Debug("job submitted", slog.String("job_id", jobID)) // Poll for completion @@ -134,15 +120,7 @@ func checkJobComplete( return false } - if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil { - logger.Error("failed to get job status", - slog.String("job_id", jobID), - slog.Int("status_code", resp.StatusCode()), - ) - return false - } - - status := cli.SafeString(resp.JSON200.Status) + status := resp.Data.Status logger.Debug("job status check", slog.String("job_id", jobID), slog.String("status", status), @@ -155,11 +133,11 @@ func checkJobComplete( ) if jsonOutput { - fmt.Println(string(resp.Body)) + fmt.Println(string(resp.RawJSON())) return true } - cli.DisplayJobDetailResponse(resp.JSON200) + cli.DisplayJobDetail(&resp.Data) return true } diff --git a/cmd/client_job_status.go b/cmd/client_job_status.go index bb4acf89..c4f71992 100644 --- a/cmd/client_job_status.go +++ b/cmd/client_job_status.go @@ -24,7 +24,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" "strings" "time" @@ -160,43 +159,26 @@ func fetchJobsStatus() string { return fmt.Sprintf("Error fetching jobs: %v", err) } - if resp.StatusCode() != http.StatusOK { - return fmt.Sprintf("Error fetching jobs: HTTP %d", resp.StatusCode()) - } - - stats := resp.JSON200 - if stats == nil { - return "Error fetching jobs: nil response" - } + stats := &resp.Data - totalJobs := 0 - if stats.TotalJobs != nil { - totalJobs = *stats.TotalJobs - } - - if totalJobs == 0 { + if stats.TotalJobs == 0 { return "Job queue is empty (0 jobs total)" } - statusCounts := map[string]int{} - if stats.StatusCounts != nil { - statusCounts = *stats.StatusCounts - } - statusDisplay := "Jobs Queue Status:\n" - statusDisplay += fmt.Sprintf(" Total Jobs: %d\n", totalJobs) - statusDisplay += fmt.Sprintf(" Unprocessed: %d\n", statusCounts["unprocessed"]) - statusDisplay += fmt.Sprintf(" Processing: %d\n", statusCounts["processing"]) - statusDisplay += fmt.Sprintf(" Completed: %d\n", statusCounts["completed"]) - statusDisplay += fmt.Sprintf(" Failed: %d\n", statusCounts["failed"]) - - if stats.DlqCount != nil && *stats.DlqCount > 0 { - statusDisplay += fmt.Sprintf(" Dead Letter Queue: %d\n", *stats.DlqCount) + statusDisplay += fmt.Sprintf(" Total Jobs: %d\n", stats.TotalJobs) + statusDisplay += fmt.Sprintf(" Unprocessed: %d\n", stats.StatusCounts["unprocessed"]) + statusDisplay += fmt.Sprintf(" Processing: %d\n", stats.StatusCounts["processing"]) + statusDisplay += fmt.Sprintf(" Completed: %d\n", stats.StatusCounts["completed"]) + statusDisplay += fmt.Sprintf(" Failed: %d\n", stats.StatusCounts["failed"]) + + if stats.DlqCount > 0 { + statusDisplay += fmt.Sprintf(" Dead Letter Queue: %d\n", stats.DlqCount) } - if stats.OperationCounts != nil && len(*stats.OperationCounts) > 0 { + if len(stats.OperationCounts) > 0 { statusDisplay += "\nOperation Types:\n" - for opType, count := range *stats.OperationCounts { + for opType, count := range stats.OperationCounts { statusDisplay += fmt.Sprintf(" %s: %d\n", opType, count) } } @@ -214,15 +196,7 @@ func fetchJobsStatusJSON() string { return string(resultJSON) } - if resp.StatusCode() != http.StatusOK { - errorResult := map[string]interface{}{ - "error": fmt.Sprintf("HTTP %d", resp.StatusCode()), - } - resultJSON, _ := json.Marshal(errorResult) - return string(resultJSON) - } - - return string(resp.Body) + return string(resp.RawJSON()) } // clientJobStatusCmd represents the clientJobsStatus command. diff --git a/cmd/client_node_command_exec.go b/cmd/client_node_command_exec.go index 757d4009..bc632135 100644 --- a/cmd/client_node_command_exec.go +++ b/cmd/client_node_command_exec.go @@ -22,12 +22,10 @@ package cmd import ( "fmt" - "net/http" "os" "strconv" "github.com/osapi-io/osapi-sdk/pkg/osapi" - "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" @@ -63,95 +61,83 @@ var clientNodeCommandExecCmd = &cobra.Command{ Target: host, }) if err != nil { - cli.LogFatal(logger, "failed to execute command", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusAccepted: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if (showStdout || showStderr) && resp.JSON202 != nil { - fmt.Println() - results := buildRawResults(resp.JSON202.Results) - cli.PrintRawOutput(os.Stdout, os.Stderr, results, showStdout, showStderr) - if code := cli.MaxExitCode(results); code != 0 { - os.Exit(code) - } - return + if showStdout || showStderr { + fmt.Println() + results := buildRawResults(resp.Data.Results) + cli.PrintRawOutput(os.Stdout, os.Stderr, results, showStdout, showStderr) + if code := cli.MaxExitCode(results); code != 0 { + os.Exit(code) } + return + } - if resp.JSON202 != nil && resp.JSON202.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON202.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - if resp.JSON202 != nil && len(resp.JSON202.Results) > 0 { - results := make([]cli.ResultRow, 0, len(resp.JSON202.Results)) - for _, r := range resp.JSON202.Results { - results = append(results, cli.ResultRow{ - Hostname: r.Hostname, - Changed: r.Changed, - Error: r.Error, - Fields: []string{ - cli.SafeString(r.Stdout), - cli.SafeString(r.Stderr), - cli.IntToSafeString(r.ExitCode), - formatDurationMs(r.DurationMs), - }, - }) + if len(resp.Data.Results) > 0 { + results := make([]cli.ResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error } - headers, rows := cli.BuildBroadcastTable(results, []string{ - "STDOUT", - "STDERR", - "EXIT CODE", - "DURATION", + var changedPtr *bool + if r.Changed { + changedPtr = &r.Changed + } + durationStr := "" + if r.DurationMs > 0 { + durationStr = strconv.FormatInt(r.DurationMs, 10) + "ms" + } + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Changed: changedPtr, + Error: errPtr, + Fields: []string{ + r.Stdout, + r.Stderr, + strconv.Itoa(r.ExitCode), + durationStr, + }, }) - cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) } - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + headers, rows := cli.BuildBroadcastTable(results, []string{ + "STDOUT", + "STDERR", + "EXIT CODE", + "DURATION", + }) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) } }, } func buildRawResults( - items []gen.CommandResultItem, + items []osapi.CommandResult, ) []cli.RawResult { results := make([]cli.RawResult, 0, len(items)) for _, r := range items { - exitCode := 0 - if r.ExitCode != nil { - exitCode = *r.ExitCode - } results = append(results, cli.RawResult{ Hostname: r.Hostname, - Stdout: cli.SafeString(r.Stdout), - Stderr: cli.SafeString(r.Stderr), - ExitCode: exitCode, + Stdout: r.Stdout, + Stderr: r.Stderr, + ExitCode: r.ExitCode, }) } return results } -func formatDurationMs( - ms *int64, -) string { - if ms == nil { - return "" - } - return strconv.FormatInt(*ms, 10) + "ms" -} - func init() { clientNodeCommandCmd.AddCommand(clientNodeCommandExecCmd) diff --git a/cmd/client_node_command_shell.go b/cmd/client_node_command_shell.go index 36af7fc8..91a9e504 100644 --- a/cmd/client_node_command_shell.go +++ b/cmd/client_node_command_shell.go @@ -22,8 +22,8 @@ package cmd import ( "fmt" - "net/http" "os" + "strconv" "github.com/osapi-io/osapi-sdk/pkg/osapi" "github.com/spf13/cobra" @@ -59,63 +59,64 @@ var clientNodeCommandShellCmd = &cobra.Command{ Target: host, }) if err != nil { - cli.LogFatal(logger, "failed to execute shell command", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusAccepted: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if (showStdout || showStderr) && resp.JSON202 != nil { - fmt.Println() - results := buildRawResults(resp.JSON202.Results) - cli.PrintRawOutput(os.Stdout, os.Stderr, results, showStdout, showStderr) - if code := cli.MaxExitCode(results); code != 0 { - os.Exit(code) - } - return + if showStdout || showStderr { + fmt.Println() + results := buildRawResults(resp.Data.Results) + cli.PrintRawOutput(os.Stdout, os.Stderr, results, showStdout, showStderr) + if code := cli.MaxExitCode(results); code != 0 { + os.Exit(code) } + return + } - if resp.JSON202 != nil && resp.JSON202.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON202.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - if resp.JSON202 != nil && len(resp.JSON202.Results) > 0 { - results := make([]cli.ResultRow, 0, len(resp.JSON202.Results)) - for _, r := range resp.JSON202.Results { - results = append(results, cli.ResultRow{ - Hostname: r.Hostname, - Changed: r.Changed, - Error: r.Error, - Fields: []string{ - cli.SafeString(r.Stdout), - cli.SafeString(r.Stderr), - cli.IntToSafeString(r.ExitCode), - formatDurationMs(r.DurationMs), - }, - }) + if len(resp.Data.Results) > 0 { + results := make([]cli.ResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + var changedPtr *bool + if r.Changed { + changedPtr = &r.Changed } - headers, rows := cli.BuildBroadcastTable(results, []string{ - "STDOUT", - "STDERR", - "EXIT CODE", - "DURATION", + durationStr := "" + if r.DurationMs > 0 { + durationStr = strconv.FormatInt(r.DurationMs, 10) + "ms" + } + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Changed: changedPtr, + Error: errPtr, + Fields: []string{ + r.Stdout, + r.Stderr, + strconv.Itoa(r.ExitCode), + durationStr, + }, }) - cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) } - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + headers, rows := cli.BuildBroadcastTable(results, []string{ + "STDOUT", + "STDERR", + "EXIT CODE", + "DURATION", + }) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) } }, } diff --git a/cmd/client_node_hostname_get.go b/cmd/client_node_hostname_get.go index 08ed3140..4484092d 100644 --- a/cmd/client_node_hostname_get.go +++ b/cmd/client_node_hostname_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "github.com/spf13/cobra" @@ -40,49 +39,34 @@ var clientNodeHostnameGetCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("target") resp, err := sdkClient.Node.Hostname(ctx, host) if err != nil { - cli.LogFatal(logger, "failed to get node hostname endpoint", 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("node hostname response was nil"), - ) - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if resp.JSON200.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON200.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - results := make([]cli.ResultRow, 0, len(resp.JSON200.Results)) - for _, h := range resp.JSON200.Results { - results = append(results, cli.ResultRow{ - Hostname: h.Hostname, - Error: h.Error, - Fields: []string{cli.FormatLabels(h.Labels)}, - }) + results := make([]cli.ResultRow, 0, len(resp.Data.Results)) + for _, h := range resp.Data.Results { + var errPtr *string + if h.Error != "" { + errPtr = &h.Error } - headers, rows := cli.BuildBroadcastTable(results, []string{"LABELS"}) - cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + results = append(results, cli.ResultRow{ + Hostname: h.Hostname, + Error: errPtr, + Fields: []string{cli.FormatLabels(h.Labels)}, + }) } + headers, rows := cli.BuildBroadcastTable(results, []string{"LABELS"}) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) }, } diff --git a/cmd/client_node_network_dns_get.go b/cmd/client_node_network_dns_get.go index 055405ec..e6b4184d 100644 --- a/cmd/client_node_network_dns_get.go +++ b/cmd/client_node_network_dns_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "net/http" "github.com/spf13/cobra" @@ -42,58 +41,40 @@ var clientNodeNetworkDNSGetCmd = &cobra.Command{ resp, err := sdkClient.Node.GetDNS(ctx, host, interfaceName) if err != nil { - cli.LogFatal(logger, "failed to get network dns endpoint", 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("get dns response was nil")) - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if resp.JSON200.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON200.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - results := make([]cli.ResultRow, 0, len(resp.JSON200.Results)) - for _, cfg := range resp.JSON200.Results { - var serversList, searchDomainsList []string - if cfg.Servers != nil { - serversList = *cfg.Servers - } - if cfg.SearchDomains != nil { - searchDomainsList = *cfg.SearchDomains - } - results = append(results, cli.ResultRow{ - Hostname: cfg.Hostname, - Error: cfg.Error, - Fields: []string{ - cli.FormatList(serversList), - cli.FormatList(searchDomainsList), - }, - }) + results := make([]cli.ResultRow, 0, len(resp.Data.Results)) + for _, cfg := range resp.Data.Results { + var errPtr *string + if cfg.Error != "" { + errPtr = &cfg.Error } - headers, rows := cli.BuildBroadcastTable(results, []string{ - "SERVERS", - "SEARCH DOMAINS", + results = append(results, cli.ResultRow{ + Hostname: cfg.Hostname, + Error: errPtr, + Fields: []string{ + cli.FormatList(cfg.Servers), + cli.FormatList(cfg.SearchDomains), + }, }) - cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) } + headers, rows := cli.BuildBroadcastTable(results, []string{ + "SERVERS", + "SEARCH DOMAINS", + }) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) }, } diff --git a/cmd/client_node_network_dns_update.go b/cmd/client_node_network_dns_update.go index b402b268..1490afb1 100644 --- a/cmd/client_node_network_dns_update.go +++ b/cmd/client_node_network_dns_update.go @@ -23,7 +23,6 @@ package cmd import ( "fmt" "log/slog" - "net/http" "strings" "github.com/spf13/cobra" @@ -61,51 +60,47 @@ var clientNodeNetworkDNSUpdateCmd = &cobra.Command{ searchDomains, ) if err != nil { - cli.LogFatal(logger, "failed to update network dns endpoint", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusAccepted: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if resp.JSON202 != nil && resp.JSON202.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON202.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - if resp.JSON202 != nil && len(resp.JSON202.Results) > 0 { - results := make([]cli.MutationResultRow, 0, len(resp.JSON202.Results)) - for _, r := range resp.JSON202.Results { - results = append(results, cli.MutationResultRow{ - Hostname: r.Hostname, - Status: string(r.Status), - Changed: r.Changed, - Error: r.Error, - }) + if len(resp.Data.Results) > 0 { + results := make([]cli.MutationResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error + } + var changedPtr *bool + if r.Changed { + changedPtr = &r.Changed } - headers, rows := cli.BuildMutationTable(results, nil) - cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) - } else { - logger.Info( - "network dns put", - slog.String("search_domains", strings.Join(searchDomains, ",")), - slog.String("servers", strings.Join(servers, ",")), - slog.Int("code", resp.StatusCode()), - slog.String("status", "ok"), - ) + results = append(results, cli.MutationResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Changed: changedPtr, + Error: errPtr, + }) } - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + headers, rows := cli.BuildMutationTable(results, nil) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + } else { + logger.Info( + "network dns put", + slog.String("search_domains", strings.Join(searchDomains, ",")), + slog.String("servers", strings.Join(servers, ",")), + slog.String("status", "ok"), + ) } }, } diff --git a/cmd/client_node_network_ping.go b/cmd/client_node_network_ping.go index 5d12e564..50010075 100644 --- a/cmd/client_node_network_ping.go +++ b/cmd/client_node_network_ping.go @@ -22,7 +22,7 @@ package cmd import ( "fmt" - "net/http" + "strconv" "github.com/spf13/cobra" @@ -42,63 +42,52 @@ var clientNodeNetworkPingCmd = &cobra.Command{ resp, err := sdkClient.Node.Ping(ctx, host, address) if err != nil { - cli.LogFatal(logger, "failed to post network ping endpoint", 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("post network ping was nil")) - } + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return + } - if resp.JSON200.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON200.JobId.String()) - } + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } - results := make([]cli.ResultRow, 0, len(resp.JSON200.Results)) - for _, r := range resp.JSON200.Results { - results = append(results, cli.ResultRow{ - Hostname: r.Hostname, - Error: r.Error, - Fields: []string{ - cli.SafeString(r.AvgRtt), - cli.SafeString(r.MaxRtt), - cli.SafeString(r.MinRtt), - cli.Float64ToSafeString(r.PacketLoss), - cli.IntToSafeString(r.PacketsReceived), - cli.IntToSafeString(r.PacketsSent), - }, - }) + results := make([]cli.ResultRow, 0, len(resp.Data.Results)) + for _, r := range resp.Data.Results { + var errPtr *string + if r.Error != "" { + errPtr = &r.Error } - headers, rows := cli.BuildBroadcastTable(results, []string{ - "AVG RTT", - "MAX RTT", - "MIN RTT", - "PACKET LOSS", - "PACKETS RECEIVED", - "PACKETS SENT", + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Error: errPtr, + Fields: []string{ + r.AvgRtt, + r.MaxRtt, + r.MinRtt, + fmt.Sprintf("%f", r.PacketLoss), + strconv.Itoa(r.PacketsReceived), + strconv.Itoa(r.PacketsSent), + }, }) - cli.PrintCompactTable([]cli.Section{{ - Title: "Ping Response", - Headers: headers, - Rows: rows, - }}) - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) } + headers, rows := cli.BuildBroadcastTable(results, []string{ + "AVG RTT", + "MAX RTT", + "MIN RTT", + "PACKET LOSS", + "PACKETS RECEIVED", + "PACKETS SENT", + }) + cli.PrintCompactTable([]cli.Section{{ + Title: "Ping Response", + Headers: headers, + Rows: rows, + }}) }, } diff --git a/cmd/client_node_status_get.go b/cmd/client_node_status_get.go index 300c688a..803dfffd 100644 --- a/cmd/client_node_status_get.go +++ b/cmd/client_node_status_get.go @@ -22,9 +22,8 @@ package cmd import ( "fmt" - "net/http" - "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" @@ -41,36 +40,21 @@ var clientNodeStatusGetCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("target") resp, err := sdkClient.Node.Status(ctx, host) if err != nil { - cli.LogFatal(logger, "failed to get node status endpoint", 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("node status response was nil")) - } - - if resp.JSON200.JobId != nil { - fmt.Println() - cli.PrintKV("Job ID", resp.JSON200.JobId.String()) - } - - displayNodeStatusCollection(host, resp.JSON200) - - case http.StatusBadRequest: - cli.HandleUnknownError(resp.JSON400, resp.StatusCode(), logger) - 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) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + if resp.Data.JobID != "" { + fmt.Println() + cli.PrintKV("Job ID", resp.Data.JobID) + } + + displayNodeStatusCollection(host, &resp.Data) }, } @@ -78,7 +62,7 @@ var clientNodeStatusGetCmd = &cobra.Command{ // For a single non-broadcast result, shows detailed output; otherwise shows a summary table. func displayNodeStatusCollection( target string, - data *gen.NodeStatusCollectionResponse, + data *osapi.Collection[osapi.NodeStatus], ) { if len(data.Results) == 1 && target != "_all" { displayNodeStatusDetail(&data.Results[0]) @@ -89,13 +73,9 @@ func displayNodeStatusCollection( results := make([]cli.ResultRow, 0, len(data.Results)) for _, s := range data.Results { - uptime := "" - if s.Uptime != nil { - uptime = *s.Uptime - } load := "" if s.LoadAverage != nil { - load = fmt.Sprintf("%.2f", s.LoadAverage.N1min) + load = fmt.Sprintf("%.2f", s.LoadAverage.OneMin) } memory := "" if s.Memory != nil { @@ -105,10 +85,14 @@ func displayNodeStatusCollection( s.Memory.Total/1024/1024/1024, ) } + var errPtr *string + if s.Error != "" { + errPtr = &s.Error + } results = append(results, cli.ResultRow{ Hostname: s.Hostname, - Error: s.Error, - Fields: []string{uptime, load, memory}, + Error: errPtr, + Fields: []string{s.Uptime, load, memory}, }) } headers, rows := cli.BuildBroadcastTable(results, []string{ @@ -121,23 +105,23 @@ func displayNodeStatusCollection( // displayNodeStatusDetail renders a single node status response with full details. func displayNodeStatusDetail( - data *gen.NodeStatusResponse, + data *osapi.NodeStatus, ) { fmt.Println() kvArgs := []string{"Hostname", data.Hostname} - if data.OsInfo != nil { + if data.OSInfo != nil { kvArgs = append( kvArgs, "OS", - data.OsInfo.Distribution+" "+cli.DimStyle.Render(data.OsInfo.Version), + data.OSInfo.Distribution+" "+cli.DimStyle.Render(data.OSInfo.Version), ) } cli.PrintKV(kvArgs...) 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)")) } @@ -150,16 +134,13 @@ func displayNodeStatusDetail( } diskRows := [][]string{} - - if data.Disks != nil { - for _, disk := range *data.Disks { - diskRows = append(diskRows, []string{ - disk.Name, - fmt.Sprintf("%d GB", disk.Total/1024/1024/1024), - fmt.Sprintf("%d GB", disk.Used/1024/1024/1024), - fmt.Sprintf("%d GB", disk.Free/1024/1024/1024), - }) - } + for _, disk := range data.Disks { + diskRows = append(diskRows, []string{ + disk.Name, + fmt.Sprintf("%d GB", disk.Total/1024/1024/1024), + fmt.Sprintf("%d GB", disk.Used/1024/1024/1024), + fmt.Sprintf("%d GB", disk.Free/1024/1024/1024), + }) } sections := []cli.Section{ diff --git a/go.mod b/go.mod index 41739c5f..03ed3848 100644 --- a/go.mod +++ b/go.mod @@ -120,8 +120,8 @@ require ( github.com/ghostiam/protogetter v0.3.20 // indirect github.com/go-critic/go-critic v0.14.3 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-git/go-git/v5 v5.16.5 // indirect + github.com/go-git/go-billy/v5 v5.8.0 // indirect + github.com/go-git/go-git/v5 v5.17.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect @@ -344,3 +344,5 @@ tool ( google.golang.org/protobuf/cmd/protoc-gen-go mvdan.cc/gofumpt ) + +replace github.com/osapi-io/osapi-sdk => ../osapi-sdk diff --git a/go.sum b/go.sum index 014b4a1c..b40b91c7 100644 --- a/go.sum +++ b/go.sum @@ -300,12 +300,12 @@ github.com/go-critic/go-critic v0.14.3 h1:5R1qH2iFeo4I/RJU8vTezdqs08Egi4u5p6vOES github.com/go-critic/go-critic v0.14.3/go.mod h1:xwntfW6SYAd7h1OqDzmN6hBX/JxsEKl5up/Y2bsxgVQ= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= -github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.16.5 h1:mdkuqblwr57kVfXri5TTH+nMFLNUxIj9Z7F5ykFbw5s= -github.com/go-git/go-git/v5 v5.16.5/go.mod h1:QOMLpNf1qxuSY4StA/ArOdfFR2TrKEjJiye2kel2m+M= +github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= +github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -755,8 +755,6 @@ github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86 h1:ML0fdgr0M4 github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86/go.mod h1:TQqODOjF2JuAOFrLtm1ItsMzPPAizKfHo+grOMuPDyE= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 h1:ELW1sTVBn5JIc17mHgd5fhpO3/7btaxJpxykG2Fe0U4= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848/go.mod h1:4rzeY9jiJF/+Ej4WNwqK5HQ2sflZrEs60GxQpg3Iya8= -github.com/osapi-io/osapi-sdk v0.0.0-20260301225211-54433f58dfd8 h1:XwUb5ZywbIX4RDBfzxGjJNCCRLxLWD3AGYu4Xn1t104= -github.com/osapi-io/osapi-sdk v0.0.0-20260301225211-54433f58dfd8/go.mod h1:bk9s5LKXaf4c2I1FAf7T7xDjn2ZqZ56Mtjoo7Ce8e6Q= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= diff --git a/internal/audit/export/export_public_test.go b/internal/audit/export/export_public_test.go index 87debae9..c1c7b692 100644 --- a/internal/audit/export/export_public_test.go +++ b/internal/audit/export/export_public_test.go @@ -27,8 +27,7 @@ import ( "testing" "time" - "github.com/google/uuid" - gen "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/audit/export" @@ -48,15 +47,15 @@ func (suite *ExportPublicTestSuite) SetupTest() { func (suite *ExportPublicTestSuite) newEntry( user string, -) gen.AuditEntry { - return gen.AuditEntry{ - Id: uuid.New(), +) osapi.AuditEntry { + return osapi.AuditEntry{ + ID: "550e8400-e29b-41d4-a716-446655440000", Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC), User: user, Roles: []string{"admin"}, Method: "GET", Path: "/node/hostname", - SourceIp: "127.0.0.1", + SourceIP: "127.0.0.1", ResponseCode: 200, DurationMs: 42, } @@ -72,7 +71,7 @@ func (suite *ExportPublicTestSuite) TestRun() { }{ { name: "when no entries returns zero counts", - fetcher: func(_ context.Context, _, _ int) ([]gen.AuditEntry, int, error) { + fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) { return nil, 0, nil }, exporter: &mockExporter{}, @@ -87,8 +86,8 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when single page exports all entries", - fetcher: func(_ context.Context, _, _ int) ([]gen.AuditEntry, int, error) { - return []gen.AuditEntry{ + fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) { + return []osapi.AuditEntry{ suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com"), }, 2, nil @@ -106,7 +105,7 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when multi-page paginates correctly", - fetcher: newPagedFetcher([][]gen.AuditEntry{ + fetcher: newPagedFetcher([][]osapi.AuditEntry{ {suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com")}, {suite.newEntry("charlie@example.com")}, }, 3), @@ -121,11 +120,11 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when fetcher errors returns partial result", - fetcher: func(_ context.Context, _, offset int) ([]gen.AuditEntry, int, error) { + fetcher: func(_ context.Context, _, offset int) ([]osapi.AuditEntry, int, error) { if offset > 0 { return nil, 0, fmt.Errorf("connection lost") } - return []gen.AuditEntry{suite.newEntry("alice@example.com")}, 3, nil + return []osapi.AuditEntry{suite.newEntry("alice@example.com")}, 3, nil }, exporter: &mockExporter{}, batchSize: 1, @@ -139,8 +138,8 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when write errors returns partial result", - fetcher: func(_ context.Context, _, _ int) ([]gen.AuditEntry, int, error) { - return []gen.AuditEntry{suite.newEntry("alice@example.com")}, 1, nil + fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) { + return []osapi.AuditEntry{suite.newEntry("alice@example.com")}, 1, nil }, exporter: &mockExporter{writeErr: fmt.Errorf("disk full")}, batchSize: 100, @@ -152,7 +151,7 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when open errors returns nil result", - fetcher: func(_ context.Context, _, _ int) ([]gen.AuditEntry, int, error) { + fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) { return nil, 0, nil }, exporter: &mockExporter{openErr: fmt.Errorf("permission denied")}, @@ -165,7 +164,7 @@ func (suite *ExportPublicTestSuite) TestRun() { }, { name: "when close errors logs warning but returns result", - fetcher: func(_ context.Context, _, _ int) ([]gen.AuditEntry, int, error) { + fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) { return nil, 0, nil }, exporter: &mockExporter{closeErr: fmt.Errorf("close failed")}, @@ -202,7 +201,7 @@ func (suite *ExportPublicTestSuite) TestRunProgress() { }{ { name: "when multi-page calls progress after each batch", - fetcher: newPagedFetcher([][]gen.AuditEntry{ + fetcher: newPagedFetcher([][]osapi.AuditEntry{ {suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com")}, {suite.newEntry("charlie@example.com")}, }, 3), @@ -246,7 +245,7 @@ func TestExportPublicTestSuite(t *testing.T) { type mockExporter struct { opened bool closed bool - entries []gen.AuditEntry + entries []osapi.AuditEntry openErr error writeErr error closeErr error @@ -264,7 +263,7 @@ func (m *mockExporter) Open( func (m *mockExporter) Write( _ context.Context, - entry gen.AuditEntry, + entry osapi.AuditEntry, ) error { if m.writeErr != nil { return m.writeErr @@ -287,14 +286,14 @@ type progressCall struct { // newPagedFetcher creates a fetcher that returns pages of entries based on offset. func newPagedFetcher( - pages [][]gen.AuditEntry, + pages [][]osapi.AuditEntry, total int, ) export.Fetcher { return func( _ context.Context, limit int, offset int, - ) ([]gen.AuditEntry, int, error) { + ) ([]osapi.AuditEntry, int, error) { _ = limit pageIdx := 0 remaining := offset diff --git a/internal/audit/export/file.go b/internal/audit/export/file.go index c2fe35ce..d765c2ca 100644 --- a/internal/audit/export/file.go +++ b/internal/audit/export/file.go @@ -28,7 +28,7 @@ import ( "io" "os" - gen "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" ) // marshalJSON is a package-level variable for testing the marshal error path. @@ -99,7 +99,7 @@ func (e *FileExporter) Open( // Write marshals an audit entry to JSON and writes it as a single line. func (e *FileExporter) Write( _ context.Context, - entry gen.AuditEntry, + entry osapi.AuditEntry, ) error { if e.writer == nil { return fmt.Errorf("exporter not opened") diff --git a/internal/audit/export/file_public_test.go b/internal/audit/export/file_public_test.go index dae0add3..301d47e4 100644 --- a/internal/audit/export/file_public_test.go +++ b/internal/audit/export/file_public_test.go @@ -31,8 +31,7 @@ import ( "testing" "time" - "github.com/google/uuid" - gen "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/audit/export" @@ -52,15 +51,15 @@ func (suite *FileExporterPublicTestSuite) SetupTest() { func (suite *FileExporterPublicTestSuite) newEntry( user string, -) gen.AuditEntry { - return gen.AuditEntry{ - Id: uuid.New(), +) osapi.AuditEntry { + return osapi.AuditEntry{ + ID: "550e8400-e29b-41d4-a716-446655440000", Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC), User: user, Roles: []string{"admin"}, Method: "GET", Path: "/node/hostname", - SourceIp: "127.0.0.1", + SourceIP: "127.0.0.1", ResponseCode: 200, DurationMs: 42, } @@ -69,17 +68,17 @@ func (suite *FileExporterPublicTestSuite) newEntry( func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() { tests := []struct { name string - entries []gen.AuditEntry + entries []osapi.AuditEntry validateFunc func(path string) }{ { name: "when single entry writes valid JSONL", - entries: []gen.AuditEntry{suite.newEntry("alice@example.com")}, + entries: []osapi.AuditEntry{suite.newEntry("alice@example.com")}, validateFunc: func(path string) { lines := suite.readLines(path) suite.Len(lines, 1) - var entry gen.AuditEntry + var entry osapi.AuditEntry err := json.Unmarshal([]byte(lines[0]), &entry) suite.NoError(err) suite.Equal("alice@example.com", entry.User) @@ -87,7 +86,7 @@ func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() { }, { name: "when multiple entries writes valid JSONL", - entries: []gen.AuditEntry{ + entries: []osapi.AuditEntry{ suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com"), suite.newEntry("charlie@example.com"), @@ -97,7 +96,7 @@ func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() { suite.Len(lines, 3) for i, user := range []string{"alice@example.com", "bob@example.com", "charlie@example.com"} { - var entry gen.AuditEntry + var entry osapi.AuditEntry err := json.Unmarshal([]byte(lines[i]), &entry) suite.NoError(err) suite.Equal(user, entry.User) diff --git a/internal/audit/export/file_test.go b/internal/audit/export/file_test.go index 6b389f25..0d21da4f 100644 --- a/internal/audit/export/file_test.go +++ b/internal/audit/export/file_test.go @@ -28,8 +28,7 @@ import ( "testing" "time" - "github.com/google/uuid" - gen "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" "github.com/stretchr/testify/suite" ) @@ -38,14 +37,14 @@ type FileTestSuite struct { } func (s *FileTestSuite) TestWriteNewlineError() { - entry := gen.AuditEntry{ - Id: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"), + entry := osapi.AuditEntry{ + ID: "550e8400-e29b-41d4-a716-446655440000", Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC), User: "user@example.com", Roles: []string{"admin"}, Method: "GET", Path: "/node/hostname", - SourceIp: "127.0.0.1", + SourceIP: "127.0.0.1", ResponseCode: 200, DurationMs: 42, } diff --git a/internal/audit/export/types.go b/internal/audit/export/types.go index 29c53a18..cf7371c4 100644 --- a/internal/audit/export/types.go +++ b/internal/audit/export/types.go @@ -23,13 +23,13 @@ package export import ( "context" - gen "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" ) // Exporter writes audit entries to a backend. type Exporter interface { Open(ctx context.Context) error - Write(ctx context.Context, entry gen.AuditEntry) error + Write(ctx context.Context, entry osapi.AuditEntry) error Close(ctx context.Context) error } @@ -39,7 +39,7 @@ type Fetcher func( ctx context.Context, limit int, offset int, -) ([]gen.AuditEntry, int, error) +) ([]osapi.AuditEntry, int, error) // Result holds export outcome. type Result struct { diff --git a/internal/cli/ui.go b/internal/cli/ui.go index bb182512..9d431229 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -22,6 +22,7 @@ package cli import ( "encoding/json" + "errors" "fmt" "log/slog" "sort" @@ -30,7 +31,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/google/uuid" - "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" ) // Theme colors for terminal UI rendering. @@ -363,21 +364,21 @@ func FormatList( // FormatLabels formats a label map as "key:value, key:value" sorted by key. func FormatLabels( - labels *map[string]string, + labels map[string]string, ) string { - if labels == nil || len(*labels) == 0 { + if len(labels) == 0 { return "" } - keys := make([]string, 0, len(*labels)) - for k := range *labels { + keys := make([]string, 0, len(labels)) + for k := range labels { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { - parts = append(parts, k+":"+(*labels)[k]) + parts = append(parts, k+":"+labels[k]) } return strings.Join(parts, ", ") } @@ -474,85 +475,64 @@ func IntToSafeString( return "N/A" } -// HandleAuthError handles authentication and authorization errors (401 and 403). -func HandleAuthError( - jsonError *gen.ErrorResponse, - statusCode int, +// HandleError logs the appropriate error message based on the SDK error type. +func HandleError( + err error, logger *slog.Logger, ) { - errorMsg := "unknown error" + var apiErr *osapi.APIError + if errors.As(err, &apiErr) { + logger.Error( + "api error", + slog.Int("code", apiErr.StatusCode), + slog.String("error", apiErr.Message), + ) - if jsonError != nil && jsonError.Error != nil { - errorMsg = SafeString(jsonError.Error) - } - - logger.Error( - "authorization error", - slog.Int("code", statusCode), - slog.String("response", errorMsg), - ) -} - -// HandleUnknownError handles unexpected errors, such as 500 Internal Server Error. -func HandleUnknownError( - json500 *gen.ErrorResponse, - statusCode int, - logger *slog.Logger, -) { - errorMsg := "unknown error" - - if json500 != nil && json500.Error != nil { - errorMsg = SafeString(json500.Error) + return } - logger.Error( - "error in response", - slog.Int("code", statusCode), - slog.String("error", errorMsg), - ) + logger.Error("error", slog.String("error", err.Error())) } -// DisplayJobDetailResponse displays detailed job information from a REST API response. +// DisplayJobDetail displays detailed job information from domain types. // Used by both job get and job run commands. -func DisplayJobDetailResponse( - resp *gen.JobDetailResponse, +func DisplayJobDetail( + resp *osapi.JobDetail, ) { // Display job metadata fmt.Println() - PrintKV("Job ID", SafeUUID(resp.Id), "Status", SafeString(resp.Status)) - if resp.Hostname != nil && *resp.Hostname != "" { - PrintKV("Hostname", *resp.Hostname) + PrintKV("Job ID", resp.ID, "Status", resp.Status) + if resp.Hostname != "" { + PrintKV("Hostname", resp.Hostname) } - if resp.Created != nil { - PrintKV("Created", *resp.Created) + if resp.Created != "" { + PrintKV("Created", resp.Created) } - if resp.UpdatedAt != nil && *resp.UpdatedAt != "" { - PrintKV("Updated At", *resp.UpdatedAt) + if resp.UpdatedAt != "" { + PrintKV("Updated At", resp.UpdatedAt) } - if resp.Error != nil && *resp.Error != "" { - PrintKV("Error", *resp.Error) + if resp.Error != "" { + PrintKV("Error", resp.Error) } // Add agent summary from agent_states - if resp.AgentStates != nil && len(*resp.AgentStates) > 0 { + if len(resp.AgentStates) > 0 { completed := 0 failed := 0 processing := 0 - for _, state := range *resp.AgentStates { - if state.Status != nil { - switch *state.Status { - case "completed": - completed++ - case "failed": - failed++ - case "started": - processing++ - } + for _, state := range resp.AgentStates { + switch state.Status { + case "completed": + completed++ + case "failed": + failed++ + case "started": + processing++ } } - total := len(*resp.AgentStates) + total := len(resp.AgentStates) if total > 1 { PrintKV("Agents", fmt.Sprintf( "%d total (%d completed, %d failed, %d processing)", @@ -568,7 +548,7 @@ func DisplayJobDetailResponse( // Display the operation request if resp.Operation != nil { - jobOperationJSON, _ := json.MarshalIndent(*resp.Operation, "", " ") + jobOperationJSON, _ := json.MarshalIndent(resp.Operation, "", " ") operationRows := [][]string{{string(jobOperationJSON)}} sections = append(sections, Section{ Title: "Job Request", @@ -578,15 +558,9 @@ func DisplayJobDetailResponse( } // Display agent responses (for broadcast jobs) - if resp.Responses != nil && len(*resp.Responses) > 0 { - responseRows := make([][]string, 0, len(*resp.Responses)) - for hostname, response := range *resp.Responses { - status := SafeString(response.Status) - errMsg := "" - if response.Error != nil { - errMsg = *response.Error - } - + if len(resp.Responses) > 0 { + responseRows := make([][]string, 0, len(resp.Responses)) + for hostname, response := range resp.Responses { var dataStr string if response.Data != nil { dataJSON, _ := json.MarshalIndent(response.Data, "", " ") @@ -595,7 +569,7 @@ func DisplayJobDetailResponse( dataStr = "(no data)" } - row := []string{hostname, status, dataStr, errMsg} + row := []string{hostname, response.Status, dataStr, response.Error} responseRows = append(responseRows, row) } @@ -607,17 +581,10 @@ func DisplayJobDetailResponse( } // Display agent states (for broadcast jobs) - if resp.AgentStates != nil && len(*resp.AgentStates) > 0 { - stateRows := make([][]string, 0, len(*resp.AgentStates)) - for hostname, state := range *resp.AgentStates { - status := SafeString(state.Status) - duration := SafeString(state.Duration) - errMsg := "" - if state.Error != nil { - errMsg = *state.Error - } - - stateRows = append(stateRows, []string{hostname, status, duration, errMsg}) + if len(resp.AgentStates) > 0 { + stateRows := make([][]string, 0, len(resp.AgentStates)) + for hostname, state := range resp.AgentStates { + stateRows = append(stateRows, []string{hostname, state.Status, state.Duration, state.Error}) } sections = append(sections, Section{ @@ -628,18 +595,10 @@ func DisplayJobDetailResponse( } // Display timeline - if resp.Timeline != nil && len(*resp.Timeline) > 0 { - timelineRows := make([][]string, 0, len(*resp.Timeline)) - for _, te := range *resp.Timeline { - ts := SafeString(te.Timestamp) - event := SafeString(te.Event) - hostname := SafeString(te.Hostname) - message := SafeString(te.Message) - errMsg := "" - if te.Error != nil { - errMsg = *te.Error - } - timelineRows = append(timelineRows, []string{ts, event, hostname, message, errMsg}) + if len(resp.Timeline) > 0 { + timelineRows := make([][]string, 0, len(resp.Timeline)) + for _, te := range resp.Timeline { + timelineRows = append(timelineRows, []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error}) } sections = append(sections, Section{ diff --git a/internal/cli/ui_public_test.go b/internal/cli/ui_public_test.go index bdf87bc3..6ac26f9b 100644 --- a/internal/cli/ui_public_test.go +++ b/internal/cli/ui_public_test.go @@ -22,6 +22,7 @@ package cli_test import ( "bytes" + "fmt" "io" "log/slog" "os" @@ -30,7 +31,7 @@ import ( "time" "github.com/google/uuid" - "github.com/osapi-io/osapi-sdk/pkg/osapi/gen" + "github.com/osapi-io/osapi-sdk/pkg/osapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -216,7 +217,7 @@ func (suite *UIPublicTestSuite) TestBuildBroadcastTable() { func (suite *UIPublicTestSuite) TestFormatLabels() { tests := []struct { name string - labels *map[string]string + labels map[string]string want string }{ { @@ -226,17 +227,17 @@ func (suite *UIPublicTestSuite) TestFormatLabels() { }, { name: "when empty map returns empty", - labels: &map[string]string{}, + labels: map[string]string{}, want: "", }, { name: "when single label formats correctly", - labels: &map[string]string{"group": "web"}, + labels: map[string]string{"group": "web"}, want: "group:web", }, { name: "when multiple labels sorts by key", - labels: &map[string]string{"group": "web", "env": "prod", "az": "us-east"}, + labels: map[string]string{"group": "web", "env": "prod", "az": "us-east"}, want: "az:us-east, env:prod, group:web", }, } @@ -564,76 +565,37 @@ func (suite *UIPublicTestSuite) TestIntToSafeString() { } } -func (suite *UIPublicTestSuite) TestHandleAuthError() { +func (suite *UIPublicTestSuite) TestHandleError() { tests := []struct { name string - jsonError *gen.ErrorResponse - code int + err error wantInLog string }{ { - name: "when error response is nil logs unknown error", - jsonError: nil, - code: 401, - wantInLog: "unknown error", - }, - { - name: "when error field is nil logs unknown error", - jsonError: &gen.ErrorResponse{Error: nil}, - code: 403, - wantInLog: "unknown error", - }, - { - name: "when error response provided logs message", - jsonError: func() *gen.ErrorResponse { - msg := "insufficient permissions" - return &gen.ErrorResponse{Error: &msg} - }(), - code: 403, + name: "when auth error logs api error with status code", + err: &osapi.AuthError{APIError: osapi.APIError{StatusCode: 403, Message: "insufficient permissions"}}, wantInLog: "insufficient permissions", }, - } - - for _, tc := range tests { - suite.Run(tc.name, func() { - var buf bytes.Buffer - logger := slog.New(slog.NewTextHandler(&buf, nil)) - - cli.HandleAuthError(tc.jsonError, tc.code, logger) - - assert.Contains(suite.T(), buf.String(), tc.wantInLog) - }) - } -} - -func (suite *UIPublicTestSuite) TestHandleUnknownError() { - tests := []struct { - name string - jsonError *gen.ErrorResponse - code int - wantInLog string - }{ { - name: "when error response is nil logs unknown error", - jsonError: nil, - code: 500, - wantInLog: "unknown error", + name: "when not found error logs api error with status code", + err: &osapi.NotFoundError{APIError: osapi.APIError{StatusCode: 404, Message: "job not found"}}, + wantInLog: "job not found", }, { - name: "when error field is nil logs unknown error", - jsonError: &gen.ErrorResponse{Error: nil}, - code: 500, - wantInLog: "unknown error", + name: "when validation error logs api error with status code", + err: &osapi.ValidationError{APIError: osapi.APIError{StatusCode: 400, Message: "invalid input"}}, + wantInLog: "invalid input", }, { - name: "when error response provided logs message", - jsonError: func() *gen.ErrorResponse { - msg := "internal server error" - return &gen.ErrorResponse{Error: &msg} - }(), - code: 500, + name: "when server error logs api error with status code", + err: &osapi.ServerError{APIError: osapi.APIError{StatusCode: 500, Message: "internal server error"}}, wantInLog: "internal server error", }, + { + name: "when generic error logs error message", + err: fmt.Errorf("connection refused"), + wantInLog: "connection refused", + }, } for _, tc := range tests { @@ -641,7 +603,7 @@ func (suite *UIPublicTestSuite) TestHandleUnknownError() { var buf bytes.Buffer logger := slog.New(slog.NewTextHandler(&buf, nil)) - cli.HandleUnknownError(tc.jsonError, tc.code, logger) + cli.HandleError(tc.err, logger) assert.Contains(suite.T(), buf.String(), tc.wantInLog) }) @@ -890,186 +852,104 @@ func (suite *UIPublicTestSuite) TestFormatBytes() { } } -func (suite *UIPublicTestSuite) TestDisplayJobDetailResponse() { +func (suite *UIPublicTestSuite) TestDisplayJobDetail() { tests := []struct { name string - resp *gen.JobDetailResponse + resp *osapi.JobDetail }{ { name: "when minimal response displays job info", - resp: func() *gen.JobDetailResponse { - status := "completed" - return &gen.JobDetailResponse{ - Status: &status, - } - }(), + resp: &osapi.JobDetail{ + Status: "completed", + }, }, { name: "when full response displays all sections", - resp: func() *gen.JobDetailResponse { - id := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000") - status := "completed" - hostname := "web-01" - created := "2026-01-01T00:00:00Z" - updatedAt := "2026-01-01T00:01:00Z" - errMsg := "timeout" - operation := map[string]interface{}{"type": "node.hostname"} - result := map[string]interface{}{"hostname": "web-01"} - event := "completed" - timestamp := "2026-01-01T00:01:00Z" - message := "job completed" - agentStatus := "completed" - duration := "1.5s" - respStatus := "ok" - - return &gen.JobDetailResponse{ - Id: &id, - Status: &status, - Hostname: &hostname, - Created: &created, - UpdatedAt: &updatedAt, - Error: &errMsg, - Operation: &operation, - Result: result, - Timeline: &[]struct { - Error *string `json:"error,omitempty"` - Event *string `json:"event,omitempty"` - Hostname *string `json:"hostname,omitempty"` - Message *string `json:"message,omitempty"` - Timestamp *string `json:"timestamp,omitempty"` - }{ - { - Event: &event, - Timestamp: ×tamp, - Hostname: &hostname, - Message: &message, - }, + resp: &osapi.JobDetail{ + ID: "550e8400-e29b-41d4-a716-446655440000", + Status: "completed", + Hostname: "web-01", + Created: "2026-01-01T00:00:00Z", + UpdatedAt: "2026-01-01T00:01:00Z", + Error: "timeout", + Operation: map[string]any{"type": "node.hostname"}, + Result: map[string]any{"hostname": "web-01"}, + Timeline: []osapi.TimelineEvent{ + { + Event: "completed", + Timestamp: "2026-01-01T00:01:00Z", + Hostname: "web-01", + Message: "job completed", }, - AgentStates: &map[string]struct { - Duration *string `json:"duration,omitempty"` - Error *string `json:"error,omitempty"` - Status *string `json:"status,omitempty"` - }{ - "web-01": { - Status: &agentStatus, - Duration: &duration, - }, + }, + AgentStates: map[string]osapi.AgentState{ + "web-01": { + Status: "completed", + Duration: "1.5s", }, - Responses: &map[string]struct { - Data interface{} `json:"data,omitempty"` - Error *string `json:"error,omitempty"` - Hostname *string `json:"hostname,omitempty"` - Status *string `json:"status,omitempty"` - }{ - "web-01": { - Status: &respStatus, - Data: map[string]string{"key": "val"}, - }, + }, + Responses: map[string]osapi.AgentJobResponse{ + "web-01": { + Status: "ok", + Data: map[string]string{"key": "val"}, }, - } - }(), + }, + }, }, { name: "when agent states with multiple agents shows summary", - resp: func() *gen.JobDetailResponse { - status := "completed" - completed := "completed" - failed := "failed" - started := "started" - duration := "1s" - errMsg := "error" - - return &gen.JobDetailResponse{ - Status: &status, - AgentStates: &map[string]struct { - Duration *string `json:"duration,omitempty"` - Error *string `json:"error,omitempty"` - Status *string `json:"status,omitempty"` - }{ - "web-01": {Status: &completed, Duration: &duration}, - "web-02": {Status: &failed, Duration: &duration, Error: &errMsg}, - "web-03": {Status: &started, Duration: &duration}, - }, - } - }(), + resp: &osapi.JobDetail{ + Status: "completed", + AgentStates: map[string]osapi.AgentState{ + "web-01": {Status: "completed", Duration: "1s"}, + "web-02": {Status: "failed", Duration: "1s", Error: "error"}, + "web-03": {Status: "started", Duration: "1s"}, + }, + }, }, { name: "when response has nil data shows no data placeholder", - resp: func() *gen.JobDetailResponse { - status := "completed" - respStatus := "ok" - - return &gen.JobDetailResponse{ - Status: &status, - Responses: &map[string]struct { - Data interface{} `json:"data,omitempty"` - Error *string `json:"error,omitempty"` - Hostname *string `json:"hostname,omitempty"` - Status *string `json:"status,omitempty"` - }{ - "web-01": { - Status: &respStatus, - Data: nil, - }, + resp: &osapi.JobDetail{ + Status: "completed", + Responses: map[string]osapi.AgentJobResponse{ + "web-01": { + Status: "ok", + Data: nil, }, - } - }(), + }, + }, }, { name: "when response has error shows error message", - resp: func() *gen.JobDetailResponse { - status := "failed" - respStatus := "failed" - errMsg := "timeout" - - return &gen.JobDetailResponse{ - Status: &status, - Responses: &map[string]struct { - Data interface{} `json:"data,omitempty"` - Error *string `json:"error,omitempty"` - Hostname *string `json:"hostname,omitempty"` - Status *string `json:"status,omitempty"` - }{ - "web-01": { - Status: &respStatus, - Error: &errMsg, - }, + resp: &osapi.JobDetail{ + Status: "failed", + Responses: map[string]osapi.AgentJobResponse{ + "web-01": { + Status: "failed", + Error: "timeout", }, - } - }(), + }, + }, }, { name: "when timeline has error shows error message", - resp: func() *gen.JobDetailResponse { - status := "failed" - event := "failed" - timestamp := "2026-01-01T00:01:00Z" - errMsg := "connection refused" - - return &gen.JobDetailResponse{ - Status: &status, - Timeline: &[]struct { - Error *string `json:"error,omitempty"` - Event *string `json:"event,omitempty"` - Hostname *string `json:"hostname,omitempty"` - Message *string `json:"message,omitempty"` - Timestamp *string `json:"timestamp,omitempty"` - }{ - { - Event: &event, - Timestamp: ×tamp, - Error: &errMsg, - }, + resp: &osapi.JobDetail{ + Status: "failed", + Timeline: []osapi.TimelineEvent{ + { + Event: "failed", + Timestamp: "2026-01-01T00:01:00Z", + Error: "connection refused", }, - } - }(), + }, + }, }, } for _, tc := range tests { suite.Run(tc.name, func() { output := captureStdout(func() { - cli.DisplayJobDetailResponse(tc.resp) + cli.DisplayJobDetail(tc.resp) }) assert.NotEmpty(suite.T(), output) From b1274b472bf6572b6ae778398621062e11d7dc93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 17:22:42 -0800 Subject: [PATCH 02/24] docs: add agent fact collection system design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for extensible fact gathering per agent, stored in a separate facts KV bucket with typed fields for common facts and a flexible map for pluggable collectors (cloud metadata, local facts). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/plans/2026-03-03-agent-facts-design.md | 214 ++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 docs/plans/2026-03-03-agent-facts-design.md diff --git a/docs/plans/2026-03-03-agent-facts-design.md b/docs/plans/2026-03-03-agent-facts-design.md new file mode 100644 index 00000000..5dcd2870 --- /dev/null +++ b/docs/plans/2026-03-03-agent-facts-design.md @@ -0,0 +1,214 @@ +# Design: Agent Fact Collection System + +## Problem + +OSAPI agents register with basic metadata via heartbeat (OS info, uptime, load, +memory), but there's no extensible fact collection system. The +osapi-orchestrator needs host-level facts to enable Ansible-style conditional +execution — "only run on Ubuntu hosts", "skip hosts with < 4GB RAM", "group +hosts by OS distribution". + +Today the orchestrator can only target by hostname or label. It can't make +decisions based on what a host *is* (architecture, kernel, network interfaces, +cloud region). + +## Design + +### Fact Categories + +**Phase 1 — Built-in facts (cheap, always collected):** + +| Category | Facts | Source | +|----------|-------|--------| +| System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `runtime.GOARCH`, `host.KernelVersion()`, `os.Hostname()` | +| Hardware | cpu_count | `runtime.NumCPU()` or `cpu.Counts()` | +| Network | interfaces (name, ipv4, mac), default gateway | `net.Interfaces()` | + +**Phase 2 — Pluggable collectors (opt-in):** + +| Collector | Facts | Source | +|-----------|-------|--------| +| Cloud | instance_id, region, instance_type, public_ip, availability_zone | Cloud metadata endpoints (AWS/GCP/Azure `169.254.169.254`) | +| Local | arbitrary key-value data | JSON/YAML files in `/etc/osapi/facts.d/` | + +All Phase 1 facts are sub-millisecond calls. Phase 2 collectors may involve +network I/O (cloud metadata) or file I/O (local facts). + +### Storage: Same API, Separate KV + +The heartbeat serves two purposes today: liveness ("I'm alive") and state +("what I look like"). Splitting these lets each optimize independently. + +**Registry KV (existing)** — lean heartbeat, frequent refresh: +- Hostname, labels, timestamps +- 10s refresh, 30s TTL +- ~200 bytes per agent + +**Facts KV (new `agent-facts` bucket)** — richer data, less frequent: +- OS, architecture, kernel, CPU, memory, interfaces, load, uptime +- Extended facts from pluggable collectors (cloud, local) +- 60s refresh, 5min TTL +- 1-10KB per agent (grows with extensible facts) + +The API merges both KVs into a single `AgentInfo` response. Consumers never +know about the split. + +### Fact Collector Interface + +Extensible via a provider pattern in the agent: + +```go +// internal/agent/facts/collector.go +type Collector interface { + Name() string + Collect(ctx context.Context) (map[string]any, error) +} +``` + +Built-in collectors (system, hardware, network) always run. Pluggable +collectors (cloud, local) are opt-in via config. Collector errors are +non-fatal — the agent writes whatever data it gathered. + +### Data Structure + +Common facts get typed fields for compile-time safety. Extended facts go +into a flexible map for forward compatibility: + +```go +type AgentRegistration struct { + // Existing fields (move to facts KV) + OSInfo *host.OSInfo `json:"os_info,omitempty"` + Uptime string `json:"uptime,omitempty"` + LoadAverages *load.AverageStats `json:"load_averages,omitempty"` + MemoryStats *mem.Stats `json:"memory_stats,omitempty"` + + // New typed facts + Architecture string `json:"architecture,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + CPUCount int `json:"cpu_count,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ServiceMgr string `json:"service_mgr,omitempty"` + PackageMgr string `json:"package_mgr,omitempty"` + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + + // Extended facts from pluggable collectors + Facts map[string]any `json:"facts,omitempty"` +} +``` + +### API Exposure + +No new endpoints. Existing `GET /node` and `GET /node/{hostname}` return +`AgentInfo` which includes all facts. The API server reads both the registry +and facts KV buckets and merges them. + +The orchestrator calls `Agent.List()` once and gets everything needed for +host filtering — no second API call. + +### Orchestrator Integration + +Facts enable four key patterns in the orchestrator DSL: + +**1. Pre-routing host discovery (filter by facts):** +```go +hosts, _ := o.Discover(ctx, "_all", + orchestrator.OS("Ubuntu"), + orchestrator.Arch("amd64"), + orchestrator.MinMemory(8 * GB), +) +``` + +**2. Fact-aware When guards:** +```go +o.CommandShell("_all", "apt upgrade -y"). + WhenFact(func(f orchestrator.Facts) bool { + return f.OS.Distribution == "Ubuntu" + }) +``` + +**3. Group-by-fact (multi-distro playbooks):** +```go +groups, _ := o.GroupByFact(ctx, "os.distribution") +for distro, hosts := range groups { + o.CommandShell(hosts[0], installCmd(distro)) +} +``` + +**4. Facts in TaskFunc (custom logic):** +```go +o.TaskFunc("decide", func(ctx context.Context, r orchestrator.Results) (*sdk.Result, error) { + agents, _ := r.ListAgents(ctx) + // Use agent facts for decisions +}) +``` + +### Configuration + +```yaml +nats: + facts: + bucket: 'agent-facts' + ttl: '5m' + storage: 'file' + replicas: 1 + +agent: + facts: + interval: '60s' + collectors: + - system + - hardware + - network + # - cloud # auto-detect cloud platform + # - local # read /etc/osapi/facts.d/ + # local_dir: /etc/osapi/facts.d +``` + +## What Changes Where + +### OSAPI (this repo) + +1. `internal/job/types.go` — add new typed fields + `Facts map[string]any` + to `AgentRegistration` and `AgentInfo` +2. `internal/agent/facts/` — new package with `Collector` interface and + built-in collectors (system, hardware, network) +3. `internal/agent/agent.go` — initialize fact collectors, start fact + refresh loop (separate from heartbeat) +4. `internal/agent/heartbeat.go` — slim down to just liveness fields + (hostname, labels, timestamps) +5. `internal/config/` — add `nats.facts` and `agent.facts` config sections +6. `internal/api/agent/gen/api.yaml` — extend `AgentInfo` schema with new + fact fields +7. `internal/job/client/query.go` — `ListAgents` and `GetAgent` merge + registry + facts KVs +8. `internal/api/` — wire facts KV into API server startup + +### SDK (osapi-sdk) + +9. Sync api.yaml, regenerate — `AgentInfo` gets new fields automatically +10. No code changes needed + +### Orchestrator (osapi-orchestrator) + +11. `Discover()` method — query `Agent.List()`, apply fact predicates +12. Fact predicates — `OS()`, `Arch()`, `MinMemory()`, `FactEquals()`, etc. +13. `WhenFact()` step method +14. `GroupByFact()` method + +## What This Does NOT Change + +- NATS routing unchanged — `_all`, `_any`, labels work as before +- No agent-side filtering — facts filter at publisher (orchestrator) side +- No new API endpoints — facts are richer `AgentInfo` data +- Labels remain the primary routing mechanism; facts are for conditional + logic and discovery +- Existing heartbeat liveness behavior unchanged + +## Phases + +- **Phase 1**: Typed facts (system, hardware, network), separate KV, + `Collector` interface, API exposure, SDK sync +- **Phase 2**: Cloud metadata collector, local facts collector, + `agent.facts` config section +- **Phase 3**: Orchestrator DSL extensions (`Discover`, `WhenFact`, + `GroupByFact`) From 66eb090252df254099a1b26fdc1493ade89b8314 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 17:33:03 -0800 Subject: [PATCH 03/24] docs: add agent facts implementation plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10-task TDD implementation plan covering types, config, collector interface, facts KV infrastructure, fact writer, merging, OpenAPI spec, and documentation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/plans/2026-03-03-agent-facts.md | 1244 ++++++++++++++++++++++++++ 1 file changed, 1244 insertions(+) create mode 100644 docs/plans/2026-03-03-agent-facts.md diff --git a/docs/plans/2026-03-03-agent-facts.md b/docs/plans/2026-03-03-agent-facts.md new file mode 100644 index 00000000..c59e77e3 --- /dev/null +++ b/docs/plans/2026-03-03-agent-facts.md @@ -0,0 +1,1244 @@ +# Agent Facts Collection System — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add extensible fact collection to agents, stored in a separate KV bucket, merged into existing API responses, enabling orchestrator-side host filtering. + +**Architecture:** Agents gather typed system facts (architecture, kernel, CPU, FQDN, network interfaces) on a 60s interval and write them to a dedicated `agent-facts` KV bucket. The job client merges facts into `AgentInfo` when serving `ListAgents`/`GetAgent`. The `Collector` interface provides the extension point for Phase 2 pluggable collectors (cloud metadata, local facts). + +**Tech Stack:** Go 1.25, NATS JetStream KV, gopsutil, oapi-codegen, testify/suite, gomock + +--- + +### Task 1: Add Types — NetworkInterface and FactsRegistration + +**Files:** +- Modify: `internal/job/types.go` +- Test: `internal/job/types_public_test.go` + +**Step 1: Write the failing test** + +In `internal/job/types_public_test.go`, add a test for JSON round-trip of `FactsRegistration`: + +```go +func (suite *TypesPublicTestSuite) TestFactsRegistrationJSON() { + tests := []struct { + name string + input job.FactsRegistration + expected string + }{ + { + name: "when all fields are populated", + input: job.FactsRegistration{ + Architecture: "amd64", + KernelVersion: "5.15.0-91-generic", + CPUCount: 4, + FQDN: "web-01.example.com", + ServiceMgr: "systemd", + PackageMgr: "apt", + Interfaces: []job.NetworkInterface{ + { + Name: "eth0", + IPv4: "192.168.1.10", + MAC: "00:11:22:33:44:55", + }, + }, + Facts: map[string]any{ + "cloud": map[string]any{ + "region": "us-east-1", + }, + }, + }, + }, + { + name: "when all fields are empty", + input: job.FactsRegistration{}, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + data, err := json.Marshal(tc.input) + suite.Require().NoError(err) + + var result job.FactsRegistration + err = json.Unmarshal(data, &result) + suite.Require().NoError(err) + + suite.Equal(tc.input.Architecture, result.Architecture) + suite.Equal(tc.input.KernelVersion, result.KernelVersion) + suite.Equal(tc.input.CPUCount, result.CPUCount) + suite.Equal(tc.input.FQDN, result.FQDN) + suite.Equal(len(tc.input.Interfaces), len(result.Interfaces)) + }) + } +} +``` + +> If `internal/job/types_public_test.go` does not exist, check for an existing +> test file (e.g., `subjects_public_test.go`) and add the suite there, or create +> the file with `package job_test` and a `TypesPublicTestSuite`. + +**Step 2: Run test to verify it fails** + +Run: `go test -run TestFactsRegistrationJSON -v ./internal/job/...` +Expected: FAIL — `FactsRegistration` and `NetworkInterface` undefined + +**Step 3: Write minimal implementation** + +Add to `internal/job/types.go` after the `AgentInfo` struct: + +```go +// NetworkInterface represents a network interface with its address. +type NetworkInterface struct { + // Name is the interface name (e.g., "eth0"). + Name string `json:"name"` + // IPv4 is the primary IPv4 address. + IPv4 string `json:"ipv4,omitempty"` + // MAC is the hardware address. + MAC string `json:"mac,omitempty"` +} + +// FactsRegistration represents an agent's facts entry in the facts KV bucket. +// This is separate from AgentRegistration (heartbeat) to allow independent +// refresh intervals and TTLs. +type FactsRegistration struct { + // Architecture is the CPU architecture (e.g., "amd64", "arm64"). + Architecture string `json:"architecture,omitempty"` + // KernelVersion is the OS kernel version. + KernelVersion string `json:"kernel_version,omitempty"` + // CPUCount is the number of logical CPUs. + CPUCount int `json:"cpu_count,omitempty"` + // FQDN is the fully qualified domain name. + FQDN string `json:"fqdn,omitempty"` + // ServiceMgr is the init system (e.g., "systemd"). + ServiceMgr string `json:"service_mgr,omitempty"` + // PackageMgr is the package manager (e.g., "apt", "yum"). + PackageMgr string `json:"package_mgr,omitempty"` + // Interfaces lists network interfaces with addresses. + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + // Facts contains extended facts from pluggable collectors. + Facts map[string]any `json:"facts,omitempty"` +} +``` + +Also add the same typed fields to `AgentInfo` (the fields that the API consumer sees): + +```go +// Add these fields to the existing AgentInfo struct, after AgentVersion: + + // Architecture is the CPU architecture (e.g., "amd64", "arm64"). + Architecture string `json:"architecture,omitempty"` + // KernelVersion is the OS kernel version. + KernelVersion string `json:"kernel_version,omitempty"` + // CPUCount is the number of logical CPUs. + CPUCount int `json:"cpu_count,omitempty"` + // FQDN is the fully qualified domain name. + FQDN string `json:"fqdn,omitempty"` + // ServiceMgr is the init system (e.g., "systemd"). + ServiceMgr string `json:"service_mgr,omitempty"` + // PackageMgr is the package manager (e.g., "apt", "yum"). + PackageMgr string `json:"package_mgr,omitempty"` + // Interfaces lists network interfaces with addresses. + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + // Facts contains extended facts from pluggable collectors. + Facts map[string]any `json:"facts,omitempty"` +``` + +**Step 4: Run test to verify it passes** + +Run: `go test -run TestFactsRegistrationJSON -v ./internal/job/...` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/job/types.go internal/job/types_public_test.go +git commit -m "feat(job): add NetworkInterface and FactsRegistration types" +``` + +--- + +### Task 2: Add Config Types — NATSFacts and AgentFacts + +**Files:** +- Modify: `internal/config/types.go` + +**Step 1: Add config structs** + +Add `NATSFacts` after `NATSRegistry` in `internal/config/types.go`: + +```go +// NATSFacts configuration for the agent facts KV bucket. +type NATSFacts struct { + // Bucket is the KV bucket name for agent facts entries. + Bucket string `mapstructure:"bucket"` + TTL string `mapstructure:"ttl"` // e.g. "5m" + Storage string `mapstructure:"storage"` // "file" or "memory" + Replicas int `mapstructure:"replicas"` +} +``` + +Add `Facts` field to the `NATS` struct: + +```go +Facts NATSFacts `mapstructure:"facts,omitempty"` +``` + +Add `AgentFacts` after `AgentConsumer`: + +```go +// AgentFacts configuration for agent fact collection. +type AgentFacts struct { + // Interval is how often facts are refreshed (e.g., "60s"). + Interval string `mapstructure:"interval"` + // Collectors lists enabled fact collectors. + Collectors []string `mapstructure:"collectors"` +} +``` + +Add `Facts` field to `AgentConfig`: + +```go +// Facts configuration for agent fact collection. +Facts AgentFacts `mapstructure:"facts,omitempty"` +``` + +**Step 2: Verify build** + +Run: `go build ./...` +Expected: compiles + +**Step 3: Commit** + +```bash +git add internal/config/types.go +git commit -m "feat(config): add NATSFacts and AgentFacts config types" +``` + +--- + +### Task 3: Add Collector Interface + +**Files:** +- Create: `internal/agent/facts/types.go` +- Test: `internal/agent/facts/types_public_test.go` + +**Step 1: Create the interface** + +Create `internal/agent/facts/types.go`: + +```go +package facts + +import "context" + +// Collector gathers extended facts from a specific source. +// Built-in collectors (system, hardware, network) are not Collectors — +// they are gathered directly. This interface is for pluggable extensions +// (cloud metadata, local facts) added in Phase 2. +type Collector interface { + // Name returns the collector's namespace (e.g., "cloud", "local"). + Name() string + // Collect gathers facts. Returns nil if not applicable (e.g., cloud + // collector on bare metal). Errors are non-fatal. + Collect(ctx context.Context) (map[string]any, error) +} +``` + +**Step 2: Write a test confirming the interface is usable** + +Create `internal/agent/facts/types_public_test.go`: + +```go +package facts_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/agent/facts" +) + +type TypesPublicTestSuite struct { + suite.Suite +} + +func TestTypesPublicTestSuite(t *testing.T) { + suite.Run(t, new(TypesPublicTestSuite)) +} + +// stubCollector is a test double that implements Collector. +type stubCollector struct { + name string + data map[string]any + err error +} + +func (s *stubCollector) Name() string { return s.name } + +func (s *stubCollector) Collect(_ context.Context) (map[string]any, error) { + return s.data, s.err +} + +func (suite *TypesPublicTestSuite) TestCollectorInterface() { + tests := []struct { + name string + collector facts.Collector + expectedName string + expectedData map[string]any + }{ + { + name: "when collector returns data", + collector: &stubCollector{ + name: "cloud", + data: map[string]any{"region": "us-east-1"}, + }, + expectedName: "cloud", + expectedData: map[string]any{"region": "us-east-1"}, + }, + { + name: "when collector returns nil", + collector: &stubCollector{ + name: "cloud", + data: nil, + }, + expectedName: "cloud", + expectedData: nil, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + suite.Equal(tc.expectedName, tc.collector.Name()) + data, _ := tc.collector.Collect(context.Background()) + suite.Equal(tc.expectedData, data) + }) + } +} +``` + +**Step 3: Run test** + +Run: `go test -v ./internal/agent/facts/...` +Expected: PASS + +**Step 4: Commit** + +```bash +git add internal/agent/facts/ +git commit -m "feat(agent): add Collector interface for extensible fact gathering" +``` + +--- + +### Task 4: Facts KV Bucket Infrastructure + +**Files:** +- Modify: `cmd/nats_helpers.go` — create facts KV bucket +- Modify: `cmd/api_helpers.go` — add `factsKV` to `natsBundle`, pass to job client +- Modify: `internal/cli/nats.go` — add `BuildFactsKVConfig` +- Modify: `internal/job/client/client.go` — add `FactsKV` to `Options` and `factsKV` to `Client` + +**Step 1: Add BuildFactsKVConfig** + +In `internal/cli/nats.go`, add after `BuildRegistryKVConfig`: + +```go +// BuildFactsKVConfig builds a jetstream.KeyValueConfig from facts config values. +func BuildFactsKVConfig( + namespace string, + factsCfg config.NATSFacts, +) jetstream.KeyValueConfig { + factsBucket := job.ApplyNamespaceToInfraName(namespace, factsCfg.Bucket) + factsTTL, _ := time.ParseDuration(factsCfg.TTL) + + return jetstream.KeyValueConfig{ + Bucket: factsBucket, + TTL: factsTTL, + Storage: ParseJetstreamStorageType(factsCfg.Storage), + Replicas: factsCfg.Replicas, + } +} +``` + +**Step 2: Create facts KV in setupJetStream** + +In `cmd/nats_helpers.go`, add after the registry KV bucket block (after line 165): + +```go +// Create facts KV bucket with configured settings +if appConfig.NATS.Facts.Bucket != "" { + factsKVConfig := cli.BuildFactsKVConfig(namespace, appConfig.NATS.Facts) + if _, err := nc.CreateOrUpdateKVBucketWithConfig(ctx, factsKVConfig); err != nil { + return fmt.Errorf("create facts KV bucket %s: %w", factsKVConfig.Bucket, err) + } +} +``` + +**Step 3: Add factsKV to natsBundle and job client** + +In `cmd/api_helpers.go`, add `factsKV` to `natsBundle`: + +```go +type natsBundle struct { + nc messaging.NATSClient + jobClient jobclient.JobClient + jobsKV jetstream.KeyValue + registryKV jetstream.KeyValue + factsKV jetstream.KeyValue +} +``` + +In `connectNATSBundle`, after the registryKV creation (after line 154), add: + +```go +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) + } +} +``` + +Add `FactsKV: factsKV` to the `jobclient.Options` and `factsKV: factsKV` to the returned `natsBundle`. + +**Step 4: Add FactsKV to job client** + +In `internal/job/client/client.go`, add `factsKV` field to `Client`: + +```go +factsKV jetstream.KeyValue // agent-facts KV (optional) +``` + +Add `FactsKV` to `Options`: + +```go +// FactsKV is the KV bucket for agent facts (optional). +FactsKV jetstream.KeyValue +``` + +In `New()`, add: `factsKV: opts.FactsKV,` + +**Step 5: Add factsKV to KVInfoFn in api_helpers.go** + +In `newMetricsProvider` `KVInfoFn`, add `b.factsKV` to the buckets slice: + +```go +buckets := []jetstream.KeyValue{jobsKV, registryKV, factsKV, auditKV} +``` + +Update the function signature to accept `factsKV jetstream.KeyValue` and +pass `b.factsKV` from `setupAPIServer`. + +**Step 6: Verify build** + +Run: `go build ./...` +Expected: compiles + +**Step 7: Commit** + +```bash +git add internal/cli/nats.go cmd/nats_helpers.go cmd/api_helpers.go internal/job/client/client.go +git commit -m "feat(nats): add facts KV bucket infrastructure" +``` + +--- + +### Task 5: Facts Writer in Agent + +**Files:** +- Create: `internal/agent/facts.go` +- Create: `internal/agent/facts_test.go` (internal tests for private functions) +- Modify: `internal/agent/types.go` — add `factsKV` and `factCollectors` fields +- Modify: `internal/agent/agent.go` — add `factsKV` param to `New()`, call `startFacts()` from `Start()` +- Modify: `cmd/agent_helpers.go` — pass `factsKV` to agent + +**Step 1: Add fields to Agent struct** + +In `internal/agent/types.go`, add after `registryKV`: + +```go +// Facts KV for writing agent facts +factsKV jetstream.KeyValue + +// Pluggable fact collectors (Phase 2) +factCollectors []facts.Collector +``` + +**Step 2: Update Agent constructor** + +In `internal/agent/agent.go`, add `factsKV jetstream.KeyValue` parameter to +`New()` (after `registryKV`). Assign it: `factsKV: factsKV,` + +**Step 3: Write failing test for writeFacts** + +Create `internal/agent/facts_test.go`: + +```go +package agent + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/suite" + "go.uber.org/mock/gomock" + + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" +) + +type FactsTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockKV *jobmocks.MockKeyValue + agent *Agent +} + +func TestFactsTestSuite(t *testing.T) { + suite.Run(t, new(FactsTestSuite)) +} + +func (s *FactsTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockKV = jobmocks.NewMockKeyValue(s.mockCtrl) + + s.agent = &Agent{ + logger: slog.Default(), + appConfig: config.Config{}, + factsKV: s.mockKV, + } +} + +func (s *FactsTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *FactsTestSuite) TestWriteFacts() { + tests := []struct { + name string + setupMock func() + validate func() + }{ + { + name: "when Put succeeds writes facts", + setupMock: func() { + s.mockKV.EXPECT(). + Put(gomock.Any(), "facts.test_host", gomock.Any()). + DoAndReturn(func(_ context.Context, _ string, data []byte) (uint64, error) { + var reg job.FactsRegistration + err := json.Unmarshal(data, ®) + s.Require().NoError(err) + s.NotEmpty(reg.Architecture) + s.Greater(reg.CPUCount, 0) + return uint64(1), nil + }) + }, + }, + { + name: "when Put fails logs warning", + setupMock: func() { + s.mockKV.EXPECT(). + Put(gomock.Any(), "facts.test_host", gomock.Any()). + Return(uint64(0), fmt.Errorf("put failed")) + }, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + tc.setupMock() + s.agent.writeFacts(context.Background(), "test-host") + }) + } +} +``` + +**Step 4: Run test to verify it fails** + +Run: `go test -run TestWriteFacts -v ./internal/agent/...` +Expected: FAIL — `writeFacts` undefined + +**Step 5: Implement facts.go** + +Create `internal/agent/facts.go`: + +```go +package agent + +import ( + "context" + "encoding/json" + "log/slog" + "net" + "runtime" + "time" + + "github.com/retr0h/osapi/internal/job" +) + +// factsInterval is the interval between fact refreshes. +var factsInterval = 60 * time.Second + +// Package-level functions for testability. +var ( + runtimeGOARCH = func() string { return runtime.GOARCH } + runtimeNumCPU = func() int { return runtime.NumCPU() } + netInterfaces = net.Interfaces + osHostname = getHostname +) + +// startFacts writes initial facts, spawns a goroutine that refreshes on a +// ticker, and stops on ctx.Done(). +func (a *Agent) startFacts( + ctx context.Context, + hostname string, +) { + if a.factsKV == nil { + return + } + + a.writeFacts(ctx, hostname) + + a.logger.Info( + "facts writer started", + slog.String("hostname", hostname), + slog.String("interval", factsInterval.String()), + ) + + a.wg.Add(1) + go func() { + defer a.wg.Done() + + ticker := time.NewTicker(factsInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.writeFacts(ctx, hostname) + } + } + }() +} + +// writeFacts gathers system facts and writes them to the facts KV bucket. +func (a *Agent) writeFacts( + ctx context.Context, + hostname string, +) { + reg := job.FactsRegistration{ + Architecture: runtimeGOARCH(), + CPUCount: runtimeNumCPU(), + } + + // Kernel version from host provider + if a.hostProvider != nil { + if info, err := a.hostProvider.GetOSInfo(); err == nil && info != nil { + // Use KernelVersion if available from gopsutil + // OSInfo is already in heartbeat; kernel comes from the same source + } + } + + // FQDN + if fqdn, err := osHostname(); err == nil { + reg.FQDN = fqdn + } + + // Network interfaces + reg.Interfaces = gatherInterfaces() + + // Service manager detection + reg.ServiceMgr = detectServiceMgr() + + // Package manager detection + reg.PackageMgr = detectPackageMgr() + + // Pluggable collectors (Phase 2) + if len(a.factCollectors) > 0 { + reg.Facts = make(map[string]any) + for _, c := range a.factCollectors { + if data, err := c.Collect(ctx); err == nil && data != nil { + reg.Facts[c.Name()] = data + } + } + } + + data, err := json.Marshal(reg) + if err != nil { + a.logger.Warn( + "failed to marshal facts", + slog.String("hostname", hostname), + slog.String("error", err.Error()), + ) + return + } + + key := factsKey(hostname) + if _, err := a.factsKV.Put(ctx, key, data); err != nil { + a.logger.Warn( + "failed to write facts", + slog.String("hostname", hostname), + slog.String("key", key), + slog.String("error", err.Error()), + ) + } +} + +// factsKey returns the KV key for an agent's facts entry. +func factsKey( + hostname string, +) string { + return "facts." + job.SanitizeHostname(hostname) +} + +// gatherInterfaces returns a list of non-loopback network interfaces. +func gatherInterfaces() []job.NetworkInterface { + ifaces, err := netInterfaces() + if err != nil { + return nil + } + + var result []job.NetworkInterface + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 { + continue + } + if iface.Flags&net.FlagUp == 0 { + continue + } + + ni := job.NetworkInterface{ + Name: iface.Name, + MAC: iface.HardwareAddr.String(), + } + + addrs, err := iface.Addrs() + if err == nil { + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { + ni.IPv4 = ipNet.IP.String() + break + } + } + } + + result = append(result, ni) + } + + return result +} + +// getHostname returns the FQDN of the current host. +func getHostname() (string, error) { + return osHostnameFunc() +} + +var osHostnameFunc = func() (string, error) { + return "", nil // Platform-specific; overridden in tests +} + +// detectServiceMgr detects the init system. +func detectServiceMgr() string { + // Check if systemd is running by looking for its PID 1 comm + // This is a best-effort detection; returns "" if unknown + return "" // Platform-specific implementation +} + +// detectPackageMgr detects the package manager. +func detectPackageMgr() string { + return "" // Platform-specific implementation +} +``` + +> **Note:** The exact implementations of `detectServiceMgr`, +> `detectPackageMgr`, `getHostname`, and kernel version depend on the +> platform. Start with stubs that return empty strings. Implement the +> Linux-specific logic in `facts_linux.go` with build tags. For now, +> the tests verify the structure and KV write mechanics. + +**Step 6: Wire into Agent Start()** + +In `internal/agent/server.go`, after the `a.startHeartbeat(a.ctx, hostname)` +call, add: + +```go +a.startFacts(a.ctx, hostname) +``` + +**Step 7: Update agent_helpers.go** + +In `cmd/agent_helpers.go`, pass `b.factsKV` to `agent.New()`. + +**Step 8: Run tests** + +Run: `go test -run TestWriteFacts -v ./internal/agent/...` +Expected: PASS + +Run: `go build ./...` +Expected: compiles + +**Step 9: Commit** + +```bash +git add internal/agent/facts.go internal/agent/facts_test.go \ + internal/agent/types.go internal/agent/agent.go \ + internal/agent/server.go cmd/agent_helpers.go +git commit -m "feat(agent): add facts writer with system fact collection" +``` + +--- + +### Task 6: Merge Facts into ListAgents and GetAgent + +**Files:** +- Modify: `internal/job/client/query.go` — merge facts KV data into AgentInfo +- Test: `internal/job/client/query_public_test.go` — test facts merging + +**Step 1: Write the failing test** + +Add a test case to the existing `TestListAgents` or add `TestListAgentsWithFacts` +in `internal/job/client/query_public_test.go`: + +```go +func (suite *QueryPublicTestSuite) TestListAgentsWithFacts() { + tests := []struct { + name string + setupRegistryKV func(kv *jobmocks.MockKeyValue) + setupFactsKV func(kv *jobmocks.MockKeyValue) + expectedArch string + expectedCPUCount int + }{ + { + name: "when facts KV has data merges into agent info", + setupRegistryKV: func(kv *jobmocks.MockKeyValue) { + reg := job.AgentRegistration{ + Hostname: "server1", + } + data, _ := json.Marshal(reg) + entry := jobmocks.NewMockKeyValueEntry(data) + kv.EXPECT().Keys(gomock.Any()).Return([]string{"agents.server1"}, nil) + kv.EXPECT().Get(gomock.Any(), "agents.server1").Return(entry, nil) + }, + setupFactsKV: func(kv *jobmocks.MockKeyValue) { + facts := job.FactsRegistration{ + Architecture: "amd64", + CPUCount: 8, + } + data, _ := json.Marshal(facts) + entry := jobmocks.NewMockKeyValueEntry(data) + kv.EXPECT().Get(gomock.Any(), "facts.server1").Return(entry, nil) + }, + expectedArch: "amd64", + expectedCPUCount: 8, + }, + { + name: "when facts KV is nil degrades gracefully", + setupRegistryKV: func(kv *jobmocks.MockKeyValue) { + reg := job.AgentRegistration{ + Hostname: "server1", + } + data, _ := json.Marshal(reg) + entry := jobmocks.NewMockKeyValueEntry(data) + kv.EXPECT().Keys(gomock.Any()).Return([]string{"agents.server1"}, nil) + kv.EXPECT().Get(gomock.Any(), "agents.server1").Return(entry, nil) + }, + // factsKV is nil — no setupFactsKV + expectedArch: "", + expectedCPUCount: 0, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + // Build client with registry and optional facts KV + // Verify agent info has merged facts + }) + } +} +``` + +> Adapt this to match the existing test patterns in `query_public_test.go`. +> Look at how `TestListAgents` is structured and follow the same mock setup. + +**Step 2: Run test to verify it fails** + +Run: `go test -run TestListAgentsWithFacts -v ./internal/job/client/...` +Expected: FAIL — facts not merged + +**Step 3: Implement facts merging** + +In `internal/job/client/query.go`, modify `ListAgents`: + +```go +func (c *Client) ListAgents( + ctx context.Context, +) ([]job.AgentInfo, error) { + if c.registryKV == nil { + return nil, fmt.Errorf("agent registry not configured") + } + + keys, err := c.registryKV.Keys(ctx) + if err != nil { + if err.Error() == "nats: no keys found" { + return []job.AgentInfo{}, nil + } + return nil, fmt.Errorf("failed to list registry keys: %w", err) + } + + agents := make([]job.AgentInfo, 0, len(keys)) + for _, key := range keys { + entry, err := c.registryKV.Get(ctx, key) + if err != nil { + continue + } + + var reg job.AgentRegistration + if err := json.Unmarshal(entry.Value(), ®); err != nil { + continue + } + + info := agentInfoFromRegistration(®) + c.mergeFacts(ctx, &info) + agents = append(agents, info) + } + + return agents, nil +} +``` + +Add `mergeFacts` helper: + +```go +// mergeFacts reads facts from the facts KV and overlays them onto AgentInfo. +func (c *Client) mergeFacts( + ctx context.Context, + info *job.AgentInfo, +) { + if c.factsKV == nil { + return + } + + key := "facts." + job.SanitizeHostname(info.Hostname) + entry, err := c.factsKV.Get(ctx, key) + if err != nil { + return // facts not available — not an error + } + + var facts job.FactsRegistration + if err := json.Unmarshal(entry.Value(), &facts); err != nil { + return + } + + info.Architecture = facts.Architecture + info.KernelVersion = facts.KernelVersion + info.CPUCount = facts.CPUCount + info.FQDN = facts.FQDN + info.ServiceMgr = facts.ServiceMgr + info.PackageMgr = facts.PackageMgr + info.Interfaces = facts.Interfaces + info.Facts = facts.Facts +} +``` + +Also update `GetAgent` to call `c.mergeFacts(ctx, &info)` after building +the AgentInfo from registration. + +**Step 4: Run tests** + +Run: `go test -run TestListAgentsWithFacts -v ./internal/job/client/...` +Expected: PASS + +Run: `go test -v ./internal/job/client/...` +Expected: all tests PASS + +**Step 5: Commit** + +```bash +git add internal/job/client/query.go internal/job/client/query_public_test.go +git commit -m "feat(job): merge facts KV data into ListAgents and GetAgent" +``` + +--- + +### Task 7: OpenAPI Spec and API Handler + +**Files:** +- Modify: `internal/api/agent/gen/api.yaml` — extend AgentInfo schema +- Run: `go generate ./internal/api/agent/gen/...` +- Modify: `internal/api/agent/agent_list.go` — update `buildAgentInfo` + +**Step 1: Extend OpenAPI spec** + +In `internal/api/agent/gen/api.yaml`, add new properties to `AgentInfo`: + +```yaml + AgentInfo: + type: object + properties: + # ... existing properties ... + architecture: + type: string + description: CPU architecture (e.g., "amd64", "arm64"). + example: "amd64" + kernel_version: + type: string + description: OS kernel version. + example: "5.15.0-91-generic" + cpu_count: + type: integer + description: Number of logical CPUs. + example: 4 + fqdn: + type: string + description: Fully qualified domain name. + example: "web-01.example.com" + service_mgr: + type: string + description: Init system (e.g., "systemd"). + example: "systemd" + package_mgr: + type: string + description: Package manager (e.g., "apt", "yum"). + example: "apt" + interfaces: + type: array + items: + $ref: '#/components/schemas/NetworkInterfaceResponse' + description: Network interfaces with addresses. + facts: + type: object + additionalProperties: true + description: Extended facts from pluggable collectors. +``` + +Add `NetworkInterfaceResponse` schema: + +```yaml + NetworkInterfaceResponse: + type: object + description: A network interface with its address. + properties: + name: + type: string + description: Interface name. + example: "eth0" + ipv4: + type: string + description: Primary IPv4 address. + example: "192.168.1.10" + mac: + type: string + description: Hardware (MAC) address. + example: "00:11:22:33:44:55" + required: + - name +``` + +**Step 2: Regenerate** + +Run: `go generate ./internal/api/agent/gen/...` +Expected: `agent.gen.go` regenerated with new fields + +**Step 3: Update buildAgentInfo** + +In `internal/api/agent/agent_list.go`, add mappings after the existing +`MemoryStats` block: + +```go +if a.Architecture != "" { + info.Architecture = &a.Architecture +} + +if a.KernelVersion != "" { + info.KernelVersion = &a.KernelVersion +} + +if a.CPUCount > 0 { + cpuCount := a.CPUCount + info.CpuCount = &cpuCount +} + +if a.FQDN != "" { + info.Fqdn = &a.FQDN +} + +if a.ServiceMgr != "" { + info.ServiceMgr = &a.ServiceMgr +} + +if a.PackageMgr != "" { + info.PackageMgr = &a.PackageMgr +} + +if len(a.Interfaces) > 0 { + ifaces := make([]gen.NetworkInterfaceResponse, 0, len(a.Interfaces)) + for _, iface := range a.Interfaces { + ni := gen.NetworkInterfaceResponse{Name: iface.Name} + if iface.IPv4 != "" { + ni.Ipv4 = &iface.IPv4 + } + if iface.MAC != "" { + ni.Mac = &iface.MAC + } + ifaces = append(ifaces, ni) + } + info.Interfaces = &ifaces +} + +if len(a.Facts) > 0 { + facts := gen.AgentInfo_Facts{AdditionalProperties: a.Facts} + info.Facts = &facts +} +``` + +> **Note:** The exact generated field names may differ. Check the generated +> `agent.gen.go` after running `go generate` and match the field names. + +**Step 4: Run tests** + +Run: `go test -v ./internal/api/agent/...` +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/api/agent/gen/api.yaml internal/api/agent/gen/agent.gen.go \ + internal/api/agent/agent_list.go +git commit -m "feat(api): expose agent facts in AgentInfo responses" +``` + +--- + +### Task 8: Default Config and Documentation + +**Files:** +- Modify: `osapi.yaml` — add `nats.facts` and `agent.facts` sections +- Modify: `docs/docs/sidebar/usage/configuration.md` — document new config + +**Step 1: Add defaults to osapi.yaml** + +```yaml +nats: + # ... existing sections ... + + # ── Facts KV bucket ────────────────────────────────────── + facts: + # KV bucket for agent facts entries. + bucket: 'agent-facts' + # TTL for facts entries (Go duration). Agents refresh + # every 60s; the TTL acts as a staleness timeout. + ttl: '5m' + # Storage backend: "file" or "memory". + storage: 'file' + # Number of KV replicas. + replicas: 1 + +agent: + # ... existing sections ... + + # Fact collection settings. + facts: + # How often to refresh facts (Go duration). + interval: '60s' + # Enabled fact collectors. Built-in: system, hardware, network. + # Phase 2: cloud, local. + collectors: + - system + - hardware + - network +``` + +**Step 2: Update configuration docs** + +Add env vars table, section reference, and full reference entries for the +new config sections in `docs/docs/sidebar/usage/configuration.md`. + +**Step 3: Commit** + +```bash +git add osapi.yaml docs/docs/sidebar/usage/configuration.md +git commit -m "docs: add facts KV and agent facts configuration" +``` + +--- + +### Task 9: Update agentInfoFromRegistration Helper + +**Files:** +- Modify: `internal/job/client/query.go` — extend `agentInfoFromRegistration` + +**Step 1: Update the helper** + +The existing `agentInfoFromRegistration` maps `AgentRegistration` → `AgentInfo`. +Since `AgentInfo` now has new fields but `AgentRegistration` does not (facts +come from the facts KV), no changes are needed to this function. The merge +happens in `mergeFacts`. + +> Verify that `agentInfoFromRegistration` compiles with the new `AgentInfo` +> fields (they have zero values by default). + +**Step 2: Verify build and all tests** + +Run: `go build ./...` +Run: `just go::unit` +Run: `just go::vet` +Expected: all pass + +**Step 3: Commit (if any changes were needed)** + +```bash +git commit -m "chore: verify agentInfoFromRegistration with new fields" +``` + +--- + +### Task 10: Final Verification + +**Step 1: Build** + +```bash +go build ./... +``` + +**Step 2: Unit tests** + +```bash +just go::unit +``` + +**Step 3: Lint** + +```bash +just go::vet +``` + +**Step 4: Format** + +```bash +just go::fmt +``` + +All must pass. Fix any issues found. + +--- + +## Out of Scope (Phase 2+) + +- Cloud metadata collector (AWS/GCP/Azure) +- Local facts collector (`/etc/osapi/facts.d/`) +- `agent.facts.collectors` config wiring (currently collectors are hardcoded) +- Linux-specific implementations of `detectServiceMgr`, `detectPackageMgr` +- Kernel version from gopsutil `host.KernelVersion()` +- Orchestrator DSL extensions (`Discover`, `WhenFact`, `GroupByFact`) +- SDK sync and regeneration From 1836de36d3a2a0d3add804b98d2b2d34bc60760c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 17:38:47 -0800 Subject: [PATCH 04/24] =?UTF-8?q?docs:=20update=20facts=20design=20and=20p?= =?UTF-8?q?lan=20=E2=80=94=20providers,=20no=20plugins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Collector interface. Route all fact gathering through the existing provider layer (extend host.Provider, new netinfo.Provider). Add documentation update tasks for configuration reference, feature pages, architecture pages, and CLI docs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/plans/2026-03-03-agent-facts-design.md | 149 +- docs/plans/2026-03-03-agent-facts.md | 1346 +++++++------------ 2 files changed, 580 insertions(+), 915 deletions(-) diff --git a/docs/plans/2026-03-03-agent-facts-design.md b/docs/plans/2026-03-03-agent-facts-design.md index 5dcd2870..328c2697 100644 --- a/docs/plans/2026-03-03-agent-facts-design.md +++ b/docs/plans/2026-03-03-agent-facts-design.md @@ -20,18 +20,18 @@ cloud region). | Category | Facts | Source | |----------|-------|--------| -| System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `runtime.GOARCH`, `host.KernelVersion()`, `os.Hostname()` | -| Hardware | cpu_count | `runtime.NumCPU()` or `cpu.Counts()` | -| Network | interfaces (name, ipv4, mac), default gateway | `net.Interfaces()` | +| System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `host.Provider` extensions | +| Hardware | cpu_count | `host.Provider` extension | +| Network | interfaces (name, ipv4, mac) | New `netinfo.Provider` | -**Phase 2 — Pluggable collectors (opt-in):** +**Phase 2 — Additional providers (opt-in):** -| Collector | Facts | Source | -|-----------|-------|--------| -| Cloud | instance_id, region, instance_type, public_ip, availability_zone | Cloud metadata endpoints (AWS/GCP/Azure `169.254.169.254`) | +| Provider | Facts | Source | +|----------|-------|--------| +| Cloud | instance_id, region, instance_type, public_ip | Cloud metadata endpoints (AWS/GCP/Azure) | | Local | arbitrary key-value data | JSON/YAML files in `/etc/osapi/facts.d/` | -All Phase 1 facts are sub-millisecond calls. Phase 2 collectors may involve +All Phase 1 facts are sub-millisecond calls. Phase 2 providers may involve network I/O (cloud metadata) or file I/O (local facts). ### Storage: Same API, Separate KV @@ -46,43 +46,40 @@ The heartbeat serves two purposes today: liveness ("I'm alive") and state **Facts KV (new `agent-facts` bucket)** — richer data, less frequent: - OS, architecture, kernel, CPU, memory, interfaces, load, uptime -- Extended facts from pluggable collectors (cloud, local) +- Extended facts from future providers - 60s refresh, 5min TTL -- 1-10KB per agent (grows with extensible facts) +- 1-10KB per agent (grows with future providers) The API merges both KVs into a single `AgentInfo` response. Consumers never know about the split. -### Fact Collector Interface +### Provider Pattern (Not a Plugin System) -Extensible via a provider pattern in the agent: +Facts are gathered through the existing provider layer — the same pattern +used for `hostProvider.GetOSInfo()`, `loadProvider.GetAverageStats()`, etc. +There is no plugin system and no `Collector` interface. -```go -// internal/agent/facts/collector.go -type Collector interface { - Name() string - Collect(ctx context.Context) (map[string]any, error) -} -``` +**Extend `host.Provider`** with new methods: +- `GetArchitecture() (string, error)` +- `GetKernelVersion() (string, error)` +- `GetFQDN() (string, error)` +- `GetCPUCount() (int, error)` +- `GetServiceManager() (string, error)` +- `GetPackageManager() (string, error)` -Built-in collectors (system, hardware, network) always run. Pluggable -collectors (cloud, local) are opt-in via config. Collector errors are -non-fatal — the agent writes whatever data it gathered. +**New `netinfo.Provider`** for network interface facts: +- `GetInterfaces() ([]NetworkInterface, error)` -### Data Structure +The facts writer calls these providers exactly like the heartbeat calls its +providers — errors are non-fatal, the agent writes whatever data it gathered. -Common facts get typed fields for compile-time safety. Extended facts go -into a flexible map for forward compatibility: +Future cloud metadata and local facts would be additional providers added +to the agent when needed, following the same pattern. + +### Data Structure ```go -type AgentRegistration struct { - // Existing fields (move to facts KV) - OSInfo *host.OSInfo `json:"os_info,omitempty"` - Uptime string `json:"uptime,omitempty"` - LoadAverages *load.AverageStats `json:"load_averages,omitempty"` - MemoryStats *mem.Stats `json:"memory_stats,omitempty"` - - // New typed facts +type FactsRegistration struct { Architecture string `json:"architecture,omitempty"` KernelVersion string `json:"kernel_version,omitempty"` CPUCount int `json:"cpu_count,omitempty"` @@ -90,15 +87,16 @@ type AgentRegistration struct { ServiceMgr string `json:"service_mgr,omitempty"` PackageMgr string `json:"package_mgr,omitempty"` Interfaces []NetworkInterface `json:"interfaces,omitempty"` - - // Extended facts from pluggable collectors - Facts map[string]any `json:"facts,omitempty"` + Facts map[string]any `json:"facts,omitempty"` } ``` +The `Facts map[string]any` field is reserved for future providers that +produce unstructured data (cloud metadata, local facts). + ### API Exposure -No new endpoints. Existing `GET /node` and `GET /node/{hostname}` return +No new endpoints. Existing `GET /agent` and `GET /agent/{hostname}` return `AgentInfo` which includes all facts. The API server reads both the registry and facts KV buckets and merges them. @@ -155,45 +153,61 @@ nats: agent: facts: interval: '60s' - collectors: - - system - - hardware - - network - # - cloud # auto-detect cloud platform - # - local # read /etc/osapi/facts.d/ - # local_dir: /etc/osapi/facts.d ``` ## What Changes Where ### OSAPI (this repo) -1. `internal/job/types.go` — add new typed fields + `Facts map[string]any` - to `AgentRegistration` and `AgentInfo` -2. `internal/agent/facts/` — new package with `Collector` interface and - built-in collectors (system, hardware, network) -3. `internal/agent/agent.go` — initialize fact collectors, start fact - refresh loop (separate from heartbeat) -4. `internal/agent/heartbeat.go` — slim down to just liveness fields - (hostname, labels, timestamps) -5. `internal/config/` — add `nats.facts` and `agent.facts` config sections -6. `internal/api/agent/gen/api.yaml` — extend `AgentInfo` schema with new - fact fields -7. `internal/job/client/query.go` — `ListAgents` and `GetAgent` merge - registry + facts KVs -8. `internal/api/` — wire facts KV into API server startup +1. `internal/job/types.go` — add `NetworkInterface`, `FactsRegistration`, + and new typed fields on `AgentInfo` +2. `internal/provider/node/host/types.go` — extend `Provider` interface + with `GetArchitecture`, `GetKernelVersion`, `GetFQDN`, `GetCPUCount`, + `GetServiceManager`, `GetPackageManager` +3. `internal/provider/node/host/ubuntu.go` (+ other platforms) — implement + new methods +4. `internal/provider/network/netinfo/` — new provider for `GetInterfaces()` +5. `internal/agent/types.go` — add `factsKV` and `netinfoProvider` fields +6. `internal/agent/agent.go` — accept new provider, start facts loop +7. `internal/agent/facts.go` — facts writer (calls providers, writes KV) +8. `internal/agent/factory.go` — create netinfo provider +9. `internal/config/types.go` — add `NATSFacts` and `AgentFacts` config +10. `cmd/nats_helpers.go` — create facts KV bucket +11. `cmd/api_helpers.go` — wire factsKV into natsBundle and job client +12. `internal/job/client/client.go` — add `FactsKV` option +13. `internal/job/client/query.go` — merge facts into ListAgents/GetAgent +14. `internal/api/agent/gen/api.yaml` — extend AgentInfo schema +15. `internal/api/agent/agent_list.go` — update buildAgentInfo mapping +16. `osapi.yaml` — default config values +17. Documentation (see below) + +### Documentation Updates + +18. `docs/docs/sidebar/features/node-management.md` — update "Agent vs. + Node" section to explain facts, add facts to "What It Manages" table +19. `docs/docs/sidebar/architecture/system-architecture.md` — add + `agent-facts` KV bucket to component map, update NATS layers +20. `docs/docs/sidebar/architecture/job-architecture.md` — add section on + facts collection, describe 60s interval and KV storage +21. `docs/docs/sidebar/usage/configuration.md` — add `nats.facts` and + `agent.facts` config sections, env var table, section reference +22. `docs/docs/sidebar/usage/cli/client/agent/list.md` — update example + output and column table with facts data +23. `docs/docs/sidebar/usage/cli/client/agent/get.md` — add facts fields + to output example and field table +24. `docs/docs/sidebar/usage/cli/client/health/status.md` — add + agent-facts bucket to KV buckets section ### SDK (osapi-sdk) -9. Sync api.yaml, regenerate — `AgentInfo` gets new fields automatically -10. No code changes needed +25. Sync api.yaml, regenerate — `AgentInfo` gets new fields automatically ### Orchestrator (osapi-orchestrator) -11. `Discover()` method — query `Agent.List()`, apply fact predicates -12. Fact predicates — `OS()`, `Arch()`, `MinMemory()`, `FactEquals()`, etc. -13. `WhenFact()` step method -14. `GroupByFact()` method +26. `Discover()` method — query `Agent.List()`, apply fact predicates +27. Fact predicates — `OS()`, `Arch()`, `MinMemory()`, `FactEquals()`, etc. +28. `WhenFact()` step method +29. `GroupByFact()` method ## What This Does NOT Change @@ -203,12 +217,11 @@ agent: - Labels remain the primary routing mechanism; facts are for conditional logic and discovery - Existing heartbeat liveness behavior unchanged +- No plugin system — facts are gathered through the provider layer ## Phases -- **Phase 1**: Typed facts (system, hardware, network), separate KV, - `Collector` interface, API exposure, SDK sync -- **Phase 2**: Cloud metadata collector, local facts collector, - `agent.facts` config section +- **Phase 1**: Typed facts via providers, separate KV, API exposure, docs +- **Phase 2**: Cloud metadata provider, local facts provider - **Phase 3**: Orchestrator DSL extensions (`Discover`, `WhenFact`, `GroupByFact`) diff --git a/docs/plans/2026-03-03-agent-facts.md b/docs/plans/2026-03-03-agent-facts.md index c59e77e3..39c16880 100644 --- a/docs/plans/2026-03-03-agent-facts.md +++ b/docs/plans/2026-03-03-agent-facts.md @@ -2,159 +2,75 @@ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. -**Goal:** Add extensible fact collection to agents, stored in a separate KV bucket, merged into existing API responses, enabling orchestrator-side host filtering. +**Goal:** Add extensible fact collection to agents via the provider layer, stored in a separate KV bucket, merged into existing API responses, enabling orchestrator-side host filtering. -**Architecture:** Agents gather typed system facts (architecture, kernel, CPU, FQDN, network interfaces) on a 60s interval and write them to a dedicated `agent-facts` KV bucket. The job client merges facts into `AgentInfo` when serving `ListAgents`/`GetAgent`. The `Collector` interface provides the extension point for Phase 2 pluggable collectors (cloud metadata, local facts). +**Architecture:** Extend `host.Provider` with new fact methods (architecture, kernel, FQDN, CPU count, service manager, package manager). Create a new `netinfo.Provider` for network interfaces. The agent gathers facts on a 60s interval and writes them to a dedicated `agent-facts` KV bucket. The job client merges facts into `AgentInfo` when serving `ListAgents`/`GetAgent`. No plugin system — everything goes through providers. **Tech Stack:** Go 1.25, NATS JetStream KV, gopsutil, oapi-codegen, testify/suite, gomock +**Design doc:** `docs/plans/2026-03-03-agent-facts-design.md` + --- -### Task 1: Add Types — NetworkInterface and FactsRegistration +### Task 1: Add Types — NetworkInterface, FactsRegistration, AgentInfo fields **Files:** - Modify: `internal/job/types.go` -- Test: `internal/job/types_public_test.go` +- Test: `internal/job/types_public_test.go` (or appropriate existing test file) **Step 1: Write the failing test** -In `internal/job/types_public_test.go`, add a test for JSON round-trip of `FactsRegistration`: +Add a test for JSON round-trip of `FactsRegistration` and `NetworkInterface`. +Use testify/suite table-driven pattern. Verify all fields serialize and +deserialize correctly, including the `Facts map[string]any` field. -```go -func (suite *TypesPublicTestSuite) TestFactsRegistrationJSON() { - tests := []struct { - name string - input job.FactsRegistration - expected string - }{ - { - name: "when all fields are populated", - input: job.FactsRegistration{ - Architecture: "amd64", - KernelVersion: "5.15.0-91-generic", - CPUCount: 4, - FQDN: "web-01.example.com", - ServiceMgr: "systemd", - PackageMgr: "apt", - Interfaces: []job.NetworkInterface{ - { - Name: "eth0", - IPv4: "192.168.1.10", - MAC: "00:11:22:33:44:55", - }, - }, - Facts: map[string]any{ - "cloud": map[string]any{ - "region": "us-east-1", - }, - }, - }, - }, - { - name: "when all fields are empty", - input: job.FactsRegistration{}, - }, - } +**Step 2: Run test to verify it fails** - for _, tc := range tests { - suite.Run(tc.name, func() { - data, err := json.Marshal(tc.input) - suite.Require().NoError(err) - - var result job.FactsRegistration - err = json.Unmarshal(data, &result) - suite.Require().NoError(err) - - suite.Equal(tc.input.Architecture, result.Architecture) - suite.Equal(tc.input.KernelVersion, result.KernelVersion) - suite.Equal(tc.input.CPUCount, result.CPUCount) - suite.Equal(tc.input.FQDN, result.FQDN) - suite.Equal(len(tc.input.Interfaces), len(result.Interfaces)) - }) - } -} +```bash +go test -run TestFactsRegistration -v ./internal/job/... ``` -> If `internal/job/types_public_test.go` does not exist, check for an existing -> test file (e.g., `subjects_public_test.go`) and add the suite there, or create -> the file with `package job_test` and a `TypesPublicTestSuite`. - -**Step 2: Run test to verify it fails** - -Run: `go test -run TestFactsRegistrationJSON -v ./internal/job/...` -Expected: FAIL — `FactsRegistration` and `NetworkInterface` undefined +Expected: FAIL — types undefined. **Step 3: Write minimal implementation** -Add to `internal/job/types.go` after the `AgentInfo` struct: +Add to `internal/job/types.go`: ```go // NetworkInterface represents a network interface with its address. type NetworkInterface struct { - // Name is the interface name (e.g., "eth0"). Name string `json:"name"` - // IPv4 is the primary IPv4 address. IPv4 string `json:"ipv4,omitempty"` - // MAC is the hardware address. - MAC string `json:"mac,omitempty"` + MAC string `json:"mac,omitempty"` } // FactsRegistration represents an agent's facts entry in the facts KV bucket. -// This is separate from AgentRegistration (heartbeat) to allow independent -// refresh intervals and TTLs. type FactsRegistration struct { - // Architecture is the CPU architecture (e.g., "amd64", "arm64"). - Architecture string `json:"architecture,omitempty"` - // KernelVersion is the OS kernel version. - KernelVersion string `json:"kernel_version,omitempty"` - // CPUCount is the number of logical CPUs. - CPUCount int `json:"cpu_count,omitempty"` - // FQDN is the fully qualified domain name. - FQDN string `json:"fqdn,omitempty"` - // ServiceMgr is the init system (e.g., "systemd"). - ServiceMgr string `json:"service_mgr,omitempty"` - // PackageMgr is the package manager (e.g., "apt", "yum"). - PackageMgr string `json:"package_mgr,omitempty"` - // Interfaces lists network interfaces with addresses. - Interfaces []NetworkInterface `json:"interfaces,omitempty"` - // Facts contains extended facts from pluggable collectors. - Facts map[string]any `json:"facts,omitempty"` + Architecture string `json:"architecture,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + CPUCount int `json:"cpu_count,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ServiceMgr string `json:"service_mgr,omitempty"` + PackageMgr string `json:"package_mgr,omitempty"` + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + Facts map[string]any `json:"facts,omitempty"` } ``` -Also add the same typed fields to `AgentInfo` (the fields that the API consumer sees): - -```go -// Add these fields to the existing AgentInfo struct, after AgentVersion: - - // Architecture is the CPU architecture (e.g., "amd64", "arm64"). - Architecture string `json:"architecture,omitempty"` - // KernelVersion is the OS kernel version. - KernelVersion string `json:"kernel_version,omitempty"` - // CPUCount is the number of logical CPUs. - CPUCount int `json:"cpu_count,omitempty"` - // FQDN is the fully qualified domain name. - FQDN string `json:"fqdn,omitempty"` - // ServiceMgr is the init system (e.g., "systemd"). - ServiceMgr string `json:"service_mgr,omitempty"` - // PackageMgr is the package manager (e.g., "apt", "yum"). - PackageMgr string `json:"package_mgr,omitempty"` - // Interfaces lists network interfaces with addresses. - Interfaces []NetworkInterface `json:"interfaces,omitempty"` - // Facts contains extended facts from pluggable collectors. - Facts map[string]any `json:"facts,omitempty"` -``` +Add the same typed fields to the existing `AgentInfo` struct (after +`AgentVersion`): `Architecture`, `KernelVersion`, `CPUCount`, `FQDN`, +`ServiceMgr`, `PackageMgr`, `Interfaces`, `Facts`. **Step 4: Run test to verify it passes** -Run: `go test -run TestFactsRegistrationJSON -v ./internal/job/...` -Expected: PASS +```bash +go test -run TestFactsRegistration -v ./internal/job/... +``` **Step 5: Commit** -```bash -git add internal/job/types.go internal/job/types_public_test.go -git commit -m "feat(job): add NetworkInterface and FactsRegistration types" +``` +feat(job): add NetworkInterface and FactsRegistration types ``` --- @@ -166,188 +82,249 @@ git commit -m "feat(job): add NetworkInterface and FactsRegistration types" **Step 1: Add config structs** -Add `NATSFacts` after `NATSRegistry` in `internal/config/types.go`: +Add `NATSFacts` after `NATSRegistry`: ```go -// NATSFacts configuration for the agent facts KV bucket. type NATSFacts struct { - // Bucket is the KV bucket name for agent facts entries. Bucket string `mapstructure:"bucket"` - TTL string `mapstructure:"ttl"` // e.g. "5m" - Storage string `mapstructure:"storage"` // "file" or "memory" + TTL string `mapstructure:"ttl"` + Storage string `mapstructure:"storage"` Replicas int `mapstructure:"replicas"` } ``` -Add `Facts` field to the `NATS` struct: - -```go -Facts NATSFacts `mapstructure:"facts,omitempty"` -``` +Add `Facts NATSFacts` field to the `NATS` struct. Add `AgentFacts` after `AgentConsumer`: ```go -// AgentFacts configuration for agent fact collection. type AgentFacts struct { - // Interval is how often facts are refreshed (e.g., "60s"). - Interval string `mapstructure:"interval"` - // Collectors lists enabled fact collectors. - Collectors []string `mapstructure:"collectors"` + Interval string `mapstructure:"interval"` } ``` -Add `Facts` field to `AgentConfig`: +Add `Facts AgentFacts` field to `AgentConfig`. + +**Step 2: Verify build** + +```bash +go build ./... +``` + +**Step 3: Commit** + +``` +feat(config): add NATSFacts and AgentFacts config types +``` + +--- + +### Task 3: Extend host.Provider with Fact Methods + +**Files:** +- Modify: `internal/provider/node/host/types.go` — add methods to interface +- Modify: `internal/provider/node/host/ubuntu.go` — implement for Ubuntu +- Modify: `internal/provider/node/host/mocks/types.gen.go` — update mock defaults +- Test: `internal/provider/node/host/ubuntu_public_test.go` or similar + +**Step 1: Write failing tests** + +Add table-driven tests for each new method: `GetArchitecture`, +`GetKernelVersion`, `GetFQDN`, `GetCPUCount`, `GetServiceManager`, +`GetPackageManager`. Test success cases and that errors don't panic. + +**Step 2: Run tests to verify they fail** + +```bash +go test -run TestGetArchitecture -v ./internal/provider/node/host/... +``` + +**Step 3: Add methods to Provider interface** + +In `internal/provider/node/host/types.go`: ```go -// Facts configuration for agent fact collection. -Facts AgentFacts `mapstructure:"facts,omitempty"` +type Provider interface { + GetUptime() (time.Duration, error) + GetHostname() (string, error) + GetOSInfo() (*OSInfo, error) + GetArchitecture() (string, error) + GetKernelVersion() (string, error) + GetFQDN() (string, error) + GetCPUCount() (int, error) + GetServiceManager() (string, error) + GetPackageManager() (string, error) +} ``` -**Step 2: Verify build** +**Step 4: Implement in Ubuntu provider** -Run: `go build ./...` -Expected: compiles +In `internal/provider/node/host/ubuntu.go`: -**Step 3: Commit** +- `GetArchitecture()` → `runtime.GOARCH` +- `GetKernelVersion()` → `host.KernelVersion()` from gopsutil +- `GetFQDN()` → `os.Hostname()` (FQDN lookup optional) +- `GetCPUCount()` → `runtime.NumCPU()` +- `GetServiceManager()` → check `/run/systemd/system` existence → `"systemd"` +- `GetPackageManager()` → check executable existence (`apt`, `yum`, `dnf`) + +Wrap gopsutil/stdlib calls in package-level function variables for testability, +following the existing pattern (e.g., `hostInfoFn`). + +**Step 5: Regenerate mocks** + +```bash +go generate ./internal/provider/node/host/... +``` + +Update `mocks/types.gen.go` to add defaults for new methods in +`NewDefaultMockProvider`: + +```go +mock.EXPECT().GetArchitecture().Return("amd64", nil).AnyTimes() +mock.EXPECT().GetKernelVersion().Return("5.15.0-91-generic", nil).AnyTimes() +mock.EXPECT().GetFQDN().Return("default-hostname.local", nil).AnyTimes() +mock.EXPECT().GetCPUCount().Return(4, nil).AnyTimes() +mock.EXPECT().GetServiceManager().Return("systemd", nil).AnyTimes() +mock.EXPECT().GetPackageManager().Return("apt", nil).AnyTimes() +``` + +**Step 6: Run all tests** ```bash -git add internal/config/types.go -git commit -m "feat(config): add NATSFacts and AgentFacts config types" +go test -v ./internal/provider/node/host/... +go build ./... +``` + +**Step 7: Commit** + +``` +feat(provider): extend host.Provider with fact methods ``` --- -### Task 3: Add Collector Interface +### Task 4: Create netinfo.Provider for Network Interfaces **Files:** -- Create: `internal/agent/facts/types.go` -- Test: `internal/agent/facts/types_public_test.go` +- Create: `internal/provider/network/netinfo/types.go` +- Create: `internal/provider/network/netinfo/netinfo.go` +- Create: `internal/provider/network/netinfo/mocks/` (generate) +- Test: `internal/provider/network/netinfo/netinfo_public_test.go` + +**Step 1: Write failing test** + +Test `GetInterfaces()` returns non-loopback, up interfaces with name, +IPv4, and MAC. Use table-driven pattern. Mock `net.Interfaces` via +a package-level function variable. -**Step 1: Create the interface** +**Step 2: Define the interface** -Create `internal/agent/facts/types.go`: +In `types.go`: ```go -package facts - -import "context" - -// Collector gathers extended facts from a specific source. -// Built-in collectors (system, hardware, network) are not Collectors — -// they are gathered directly. This interface is for pluggable extensions -// (cloud metadata, local facts) added in Phase 2. -type Collector interface { - // Name returns the collector's namespace (e.g., "cloud", "local"). - Name() string - // Collect gathers facts. Returns nil if not applicable (e.g., cloud - // collector on bare metal). Errors are non-fatal. - Collect(ctx context.Context) (map[string]any, error) +package netinfo + +import "github.com/retr0h/osapi/internal/job" + +type Provider interface { + GetInterfaces() ([]job.NetworkInterface, error) } ``` -**Step 2: Write a test confirming the interface is usable** +**Step 3: Implement** -Create `internal/agent/facts/types_public_test.go`: +In `netinfo.go`: ```go -package facts_test +package netinfo import ( - "context" - "testing" - - "github.com/stretchr/testify/suite" + "net" - "github.com/retr0h/osapi/internal/agent/facts" + "github.com/retr0h/osapi/internal/job" ) -type TypesPublicTestSuite struct { - suite.Suite -} +type Netinfo struct{} -func TestTypesPublicTestSuite(t *testing.T) { - suite.Run(t, new(TypesPublicTestSuite)) -} +func New() *Netinfo { return &Netinfo{} } -// stubCollector is a test double that implements Collector. -type stubCollector struct { - name string - data map[string]any - err error -} +var netInterfacesFn = net.Interfaces -func (s *stubCollector) Name() string { return s.name } +func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { + ifaces, err := netInterfacesFn() + if err != nil { + return nil, err + } -func (s *stubCollector) Collect(_ context.Context) (map[string]any, error) { - return s.data, s.err -} + var result []job.NetworkInterface + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } -func (suite *TypesPublicTestSuite) TestCollectorInterface() { - tests := []struct { - name string - collector facts.Collector - expectedName string - expectedData map[string]any - }{ - { - name: "when collector returns data", - collector: &stubCollector{ - name: "cloud", - data: map[string]any{"region": "us-east-1"}, - }, - expectedName: "cloud", - expectedData: map[string]any{"region": "us-east-1"}, - }, - { - name: "when collector returns nil", - collector: &stubCollector{ - name: "cloud", - data: nil, - }, - expectedName: "cloud", - expectedData: nil, - }, - } + ni := job.NetworkInterface{ + Name: iface.Name, + MAC: iface.HardwareAddr.String(), + } - for _, tc := range tests { - suite.Run(tc.name, func() { - suite.Equal(tc.expectedName, tc.collector.Name()) - data, _ := tc.collector.Collect(context.Background()) - suite.Equal(tc.expectedData, data) - }) + addrs, err := iface.Addrs() + if err == nil { + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { + ni.IPv4 = ipNet.IP.String() + break + } + } + } + + result = append(result, ni) } + + return result, nil } ``` -**Step 3: Run test** +**Step 4: Generate mocks and add defaults** -Run: `go test -v ./internal/agent/facts/...` -Expected: PASS +```bash +# Add generate.go with //go:generate directive +go generate ./internal/provider/network/netinfo/... +``` -**Step 4: Commit** +Create `mocks/types.gen.go` with `NewDefaultMockProvider` returning +a stub interface list. + +**Step 5: Run tests** ```bash -git add internal/agent/facts/ -git commit -m "feat(agent): add Collector interface for extensible fact gathering" +go test -v ./internal/provider/network/netinfo/... +``` + +**Step 6: Commit** + +``` +feat(provider): add netinfo.Provider for network interface facts ``` --- -### Task 4: Facts KV Bucket Infrastructure +### Task 5: Facts KV Bucket Infrastructure **Files:** -- Modify: `cmd/nats_helpers.go` — create facts KV bucket -- Modify: `cmd/api_helpers.go` — add `factsKV` to `natsBundle`, pass to job client - Modify: `internal/cli/nats.go` — add `BuildFactsKVConfig` -- Modify: `internal/job/client/client.go` — add `FactsKV` to `Options` and `factsKV` to `Client` +- Modify: `cmd/nats_helpers.go` — create facts KV in `setupJetStream` +- Modify: `cmd/api_helpers.go` — add `factsKV` to `natsBundle`, pass to + job client and metrics provider +- Modify: `internal/job/client/client.go` — add `FactsKV` to `Options` + and `factsKV` to `Client` **Step 1: Add BuildFactsKVConfig** -In `internal/cli/nats.go`, add after `BuildRegistryKVConfig`: +In `internal/cli/nats.go`, add after `BuildRegistryKVConfig` (follow the +exact same pattern): ```go -// BuildFactsKVConfig builds a jetstream.KeyValueConfig from facts config values. func BuildFactsKVConfig( namespace string, factsCfg config.NATSFacts, @@ -366,10 +343,9 @@ func BuildFactsKVConfig( **Step 2: Create facts KV in setupJetStream** -In `cmd/nats_helpers.go`, add after the registry KV bucket block (after line 165): +In `cmd/nats_helpers.go`, add after the registry KV block (line ~165): ```go -// Create facts KV bucket with configured settings if appConfig.NATS.Facts.Bucket != "" { factsKVConfig := cli.BuildFactsKVConfig(namespace, appConfig.NATS.Facts) if _, err := nc.CreateOrUpdateKVBucketWithConfig(ctx, factsKVConfig); err != nil { @@ -378,194 +354,83 @@ if appConfig.NATS.Facts.Bucket != "" { } ``` -**Step 3: Add factsKV to natsBundle and job client** +**Step 3: Wire into natsBundle and job client** -In `cmd/api_helpers.go`, add `factsKV` to `natsBundle`: +Add `factsKV jetstream.KeyValue` to `natsBundle` struct. -```go -type natsBundle struct { - nc messaging.NATSClient - jobClient jobclient.JobClient - jobsKV jetstream.KeyValue - registryKV jetstream.KeyValue - factsKV jetstream.KeyValue -} -``` +In `connectNATSBundle`, create the facts KV bucket (only if configured) +and pass it as `FactsKV` in `jobclient.Options`. -In `connectNATSBundle`, after the registryKV creation (after line 154), add: +Add `factsKV` to the returned `natsBundle`. -```go -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) - } -} -``` +In `newMetricsProvider`, add `b.factsKV` to the `KVInfoFn` buckets slice. -Add `FactsKV: factsKV` to the `jobclient.Options` and `factsKV: factsKV` to the returned `natsBundle`. +**Step 4: Add to job client** -**Step 4: Add FactsKV to job client** +In `internal/job/client/client.go`: +- Add `FactsKV jetstream.KeyValue` to `Options` +- Add `factsKV jetstream.KeyValue` to `Client` struct +- Assign in `New()`: `factsKV: opts.FactsKV,` -In `internal/job/client/client.go`, add `factsKV` field to `Client`: +**Step 5: Verify build** -```go -factsKV jetstream.KeyValue // agent-facts KV (optional) +```bash +go build ./... ``` -Add `FactsKV` to `Options`: +**Step 6: Commit** -```go -// FactsKV is the KV bucket for agent facts (optional). -FactsKV jetstream.KeyValue ``` - -In `New()`, add: `factsKV: opts.FactsKV,` - -**Step 5: Add factsKV to KVInfoFn in api_helpers.go** - -In `newMetricsProvider` `KVInfoFn`, add `b.factsKV` to the buckets slice: - -```go -buckets := []jetstream.KeyValue{jobsKV, registryKV, factsKV, auditKV} -``` - -Update the function signature to accept `factsKV jetstream.KeyValue` and -pass `b.factsKV` from `setupAPIServer`. - -**Step 6: Verify build** - -Run: `go build ./...` -Expected: compiles - -**Step 7: Commit** - -```bash -git add internal/cli/nats.go cmd/nats_helpers.go cmd/api_helpers.go internal/job/client/client.go -git commit -m "feat(nats): add facts KV bucket infrastructure" +feat(nats): add facts KV bucket infrastructure ``` --- -### Task 5: Facts Writer in Agent +### Task 6: Facts Writer in Agent **Files:** - Create: `internal/agent/facts.go` -- Create: `internal/agent/facts_test.go` (internal tests for private functions) -- Modify: `internal/agent/types.go` — add `factsKV` and `factCollectors` fields -- Modify: `internal/agent/agent.go` — add `factsKV` param to `New()`, call `startFacts()` from `Start()` -- Modify: `cmd/agent_helpers.go` — pass `factsKV` to agent +- Create: `internal/agent/facts_test.go` (internal tests) +- Modify: `internal/agent/types.go` — add `factsKV` and `netinfoProvider` +- Modify: `internal/agent/agent.go` — add params to `New()`, call + `startFacts()` in `Start()` +- Modify: `internal/agent/factory.go` — create netinfo provider +- Modify: `cmd/agent_helpers.go` — pass `factsKV` and netinfo provider **Step 1: Add fields to Agent struct** -In `internal/agent/types.go`, add after `registryKV`: +In `internal/agent/types.go`, add: ```go -// Facts KV for writing agent facts -factsKV jetstream.KeyValue - -// Pluggable fact collectors (Phase 2) -factCollectors []facts.Collector +factsKV jetstream.KeyValue +netinfoProvider netinfo.Provider ``` -**Step 2: Update Agent constructor** - -In `internal/agent/agent.go`, add `factsKV jetstream.KeyValue` parameter to -`New()` (after `registryKV`). Assign it: `factsKV: factsKV,` - -**Step 3: Write failing test for writeFacts** - -Create `internal/agent/facts_test.go`: - -```go -package agent - -import ( - "context" - "encoding/json" - "testing" - - "github.com/stretchr/testify/suite" - "go.uber.org/mock/gomock" - - "github.com/retr0h/osapi/internal/config" - "github.com/retr0h/osapi/internal/job" - jobmocks "github.com/retr0h/osapi/internal/job/mocks" -) - -type FactsTestSuite struct { - suite.Suite - - mockCtrl *gomock.Controller - mockKV *jobmocks.MockKeyValue - agent *Agent -} - -func TestFactsTestSuite(t *testing.T) { - suite.Run(t, new(FactsTestSuite)) -} +**Step 2: Update New() and factory** -func (s *FactsTestSuite) SetupTest() { - s.mockCtrl = gomock.NewController(s.T()) - s.mockKV = jobmocks.NewMockKeyValue(s.mockCtrl) +In `internal/agent/agent.go`, add `netinfoProvider netinfo.Provider` and +`factsKV jetstream.KeyValue` parameters. Assign them. - s.agent = &Agent{ - logger: slog.Default(), - appConfig: config.Config{}, - factsKV: s.mockKV, - } -} +In `internal/agent/factory.go`, add `netinfo.New()` to the provider +factory return values. Update `CreateProviders()` signature. -func (s *FactsTestSuite) TearDownTest() { - s.mockCtrl.Finish() -} +**Step 3: Write failing test for writeFacts** -func (s *FactsTestSuite) TestWriteFacts() { - tests := []struct { - name string - setupMock func() - validate func() - }{ - { - name: "when Put succeeds writes facts", - setupMock: func() { - s.mockKV.EXPECT(). - Put(gomock.Any(), "facts.test_host", gomock.Any()). - DoAndReturn(func(_ context.Context, _ string, data []byte) (uint64, error) { - var reg job.FactsRegistration - err := json.Unmarshal(data, ®) - s.Require().NoError(err) - s.NotEmpty(reg.Architecture) - s.Greater(reg.CPUCount, 0) - return uint64(1), nil - }) - }, - }, - { - name: "when Put fails logs warning", - setupMock: func() { - s.mockKV.EXPECT(). - Put(gomock.Any(), "facts.test_host", gomock.Any()). - Return(uint64(0), fmt.Errorf("put failed")) - }, - }, - } +Create `internal/agent/facts_test.go` (internal, `package agent`). +Use `FactsTestSuite` with gomock. Mock the `factsKV.Put()` call. +Verify the written data contains architecture, cpu_count, interfaces. +Follow the existing `heartbeat_test.go` pattern exactly. - for _, tc := range tests { - s.Run(tc.name, func() { - tc.setupMock() - s.agent.writeFacts(context.Background(), "test-host") - }) - } -} -``` +Test cases: +- `"when Put succeeds writes facts"` — verify JSON contains expected fields +- `"when Put fails logs warning"` — verify no panic +- `"when marshal fails logs warning"` — override `marshalJSON` variable **Step 4: Run test to verify it fails** -Run: `go test -run TestWriteFacts -v ./internal/agent/...` -Expected: FAIL — `writeFacts` undefined +```bash +go test -run TestWriteFacts -v ./internal/agent/... +``` **Step 5: Implement facts.go** @@ -574,53 +439,19 @@ Create `internal/agent/facts.go`: ```go package agent -import ( - "context" - "encoding/json" - "log/slog" - "net" - "runtime" - "time" - - "github.com/retr0h/osapi/internal/job" -) - -// factsInterval is the interval between fact refreshes. +// factsInterval controls the fact refresh period. var factsInterval = 60 * time.Second -// Package-level functions for testability. -var ( - runtimeGOARCH = func() string { return runtime.GOARCH } - runtimeNumCPU = func() int { return runtime.NumCPU() } - netInterfaces = net.Interfaces - osHostname = getHostname -) - -// startFacts writes initial facts, spawns a goroutine that refreshes on a -// ticker, and stops on ctx.Done(). -func (a *Agent) startFacts( - ctx context.Context, - hostname string, -) { +func (a *Agent) startFacts(ctx context.Context, hostname string) { if a.factsKV == nil { return } - a.writeFacts(ctx, hostname) - - a.logger.Info( - "facts writer started", - slog.String("hostname", hostname), - slog.String("interval", factsInterval.String()), - ) - a.wg.Add(1) go func() { defer a.wg.Done() - ticker := time.NewTicker(factsInterval) defer ticker.Stop() - for { select { case <-ctx.Done(): @@ -632,299 +463,103 @@ func (a *Agent) startFacts( }() } -// writeFacts gathers system facts and writes them to the facts KV bucket. -func (a *Agent) writeFacts( - ctx context.Context, - hostname string, -) { - reg := job.FactsRegistration{ - Architecture: runtimeGOARCH(), - CPUCount: runtimeNumCPU(), - } +func (a *Agent) writeFacts(ctx context.Context, hostname string) { + reg := job.FactsRegistration{} - // Kernel version from host provider - if a.hostProvider != nil { - if info, err := a.hostProvider.GetOSInfo(); err == nil && info != nil { - // Use KernelVersion if available from gopsutil - // OSInfo is already in heartbeat; kernel comes from the same source - } + // Call providers — errors are non-fatal + if arch, err := a.hostProvider.GetArchitecture(); err == nil { + reg.Architecture = arch } - - // FQDN - if fqdn, err := osHostname(); err == nil { + if kv, err := a.hostProvider.GetKernelVersion(); err == nil { + reg.KernelVersion = kv + } + if fqdn, err := a.hostProvider.GetFQDN(); err == nil { reg.FQDN = fqdn } - - // Network interfaces - reg.Interfaces = gatherInterfaces() - - // Service manager detection - reg.ServiceMgr = detectServiceMgr() - - // Package manager detection - reg.PackageMgr = detectPackageMgr() - - // Pluggable collectors (Phase 2) - if len(a.factCollectors) > 0 { - reg.Facts = make(map[string]any) - for _, c := range a.factCollectors { - if data, err := c.Collect(ctx); err == nil && data != nil { - reg.Facts[c.Name()] = data - } - } + if count, err := a.hostProvider.GetCPUCount(); err == nil { + reg.CPUCount = count + } + if mgr, err := a.hostProvider.GetServiceManager(); err == nil { + reg.ServiceMgr = mgr + } + if mgr, err := a.hostProvider.GetPackageManager(); err == nil { + reg.PackageMgr = mgr + } + if ifaces, err := a.netinfoProvider.GetInterfaces(); err == nil { + reg.Interfaces = ifaces } - data, err := json.Marshal(reg) + data, err := marshalJSON(reg) if err != nil { - a.logger.Warn( - "failed to marshal facts", - slog.String("hostname", hostname), - slog.String("error", err.Error()), - ) + a.logger.Warn("failed to marshal facts", ...) return } key := factsKey(hostname) if _, err := a.factsKV.Put(ctx, key, data); err != nil { - a.logger.Warn( - "failed to write facts", - slog.String("hostname", hostname), - slog.String("key", key), - slog.String("error", err.Error()), - ) + a.logger.Warn("failed to write facts", ...) } } -// factsKey returns the KV key for an agent's facts entry. -func factsKey( - hostname string, -) string { +func factsKey(hostname string) string { return "facts." + job.SanitizeHostname(hostname) } - -// gatherInterfaces returns a list of non-loopback network interfaces. -func gatherInterfaces() []job.NetworkInterface { - ifaces, err := netInterfaces() - if err != nil { - return nil - } - - var result []job.NetworkInterface - for _, iface := range ifaces { - if iface.Flags&net.FlagLoopback != 0 { - continue - } - if iface.Flags&net.FlagUp == 0 { - continue - } - - ni := job.NetworkInterface{ - Name: iface.Name, - MAC: iface.HardwareAddr.String(), - } - - addrs, err := iface.Addrs() - if err == nil { - for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { - ni.IPv4 = ipNet.IP.String() - break - } - } - } - - result = append(result, ni) - } - - return result -} - -// getHostname returns the FQDN of the current host. -func getHostname() (string, error) { - return osHostnameFunc() -} - -var osHostnameFunc = func() (string, error) { - return "", nil // Platform-specific; overridden in tests -} - -// detectServiceMgr detects the init system. -func detectServiceMgr() string { - // Check if systemd is running by looking for its PID 1 comm - // This is a best-effort detection; returns "" if unknown - return "" // Platform-specific implementation -} - -// detectPackageMgr detects the package manager. -func detectPackageMgr() string { - return "" // Platform-specific implementation -} ``` -> **Note:** The exact implementations of `detectServiceMgr`, -> `detectPackageMgr`, `getHostname`, and kernel version depend on the -> platform. Start with stubs that return empty strings. Implement the -> Linux-specific logic in `facts_linux.go` with build tags. For now, -> the tests verify the structure and KV write mechanics. +**Step 6: Wire into Start()** -**Step 6: Wire into Agent Start()** - -In `internal/agent/server.go`, after the `a.startHeartbeat(a.ctx, hostname)` -call, add: +In `internal/agent/server.go`, after `a.startHeartbeat(a.ctx, hostname)`: ```go a.startFacts(a.ctx, hostname) ``` -**Step 7: Update agent_helpers.go** +**Step 7: Update cmd/agent_helpers.go** -In `cmd/agent_helpers.go`, pass `b.factsKV` to `agent.New()`. +Pass `b.factsKV` and the netinfo provider to `agent.New()`. **Step 8: Run tests** -Run: `go test -run TestWriteFacts -v ./internal/agent/...` -Expected: PASS - -Run: `go build ./...` -Expected: compiles +```bash +go test -v ./internal/agent/... +go build ./... +``` **Step 9: Commit** -```bash -git add internal/agent/facts.go internal/agent/facts_test.go \ - internal/agent/types.go internal/agent/agent.go \ - internal/agent/server.go cmd/agent_helpers.go -git commit -m "feat(agent): add facts writer with system fact collection" +``` +feat(agent): add facts writer with provider-based collection ``` --- -### Task 6: Merge Facts into ListAgents and GetAgent +### Task 7: Merge Facts into ListAgents and GetAgent **Files:** -- Modify: `internal/job/client/query.go` — merge facts KV data into AgentInfo -- Test: `internal/job/client/query_public_test.go` — test facts merging - -**Step 1: Write the failing test** - -Add a test case to the existing `TestListAgents` or add `TestListAgentsWithFacts` -in `internal/job/client/query_public_test.go`: +- Modify: `internal/job/client/query.go` — add `mergeFacts` helper +- Test: `internal/job/client/query_public_test.go` -```go -func (suite *QueryPublicTestSuite) TestListAgentsWithFacts() { - tests := []struct { - name string - setupRegistryKV func(kv *jobmocks.MockKeyValue) - setupFactsKV func(kv *jobmocks.MockKeyValue) - expectedArch string - expectedCPUCount int - }{ - { - name: "when facts KV has data merges into agent info", - setupRegistryKV: func(kv *jobmocks.MockKeyValue) { - reg := job.AgentRegistration{ - Hostname: "server1", - } - data, _ := json.Marshal(reg) - entry := jobmocks.NewMockKeyValueEntry(data) - kv.EXPECT().Keys(gomock.Any()).Return([]string{"agents.server1"}, nil) - kv.EXPECT().Get(gomock.Any(), "agents.server1").Return(entry, nil) - }, - setupFactsKV: func(kv *jobmocks.MockKeyValue) { - facts := job.FactsRegistration{ - Architecture: "amd64", - CPUCount: 8, - } - data, _ := json.Marshal(facts) - entry := jobmocks.NewMockKeyValueEntry(data) - kv.EXPECT().Get(gomock.Any(), "facts.server1").Return(entry, nil) - }, - expectedArch: "amd64", - expectedCPUCount: 8, - }, - { - name: "when facts KV is nil degrades gracefully", - setupRegistryKV: func(kv *jobmocks.MockKeyValue) { - reg := job.AgentRegistration{ - Hostname: "server1", - } - data, _ := json.Marshal(reg) - entry := jobmocks.NewMockKeyValueEntry(data) - kv.EXPECT().Keys(gomock.Any()).Return([]string{"agents.server1"}, nil) - kv.EXPECT().Get(gomock.Any(), "agents.server1").Return(entry, nil) - }, - // factsKV is nil — no setupFactsKV - expectedArch: "", - expectedCPUCount: 0, - }, - } +**Step 1: Write failing test** - for _, tc := range tests { - suite.Run(tc.name, func() { - // Build client with registry and optional facts KV - // Verify agent info has merged facts - }) - } -} -``` +Add test cases for facts merging. Test: +- Facts KV has data → fields appear in AgentInfo +- Facts KV is nil → graceful degradation (fields empty) +- Facts KV Get returns error → graceful degradation -> Adapt this to match the existing test patterns in `query_public_test.go`. -> Look at how `TestListAgents` is structured and follow the same mock setup. +Follow existing test patterns in `query_public_test.go`. **Step 2: Run test to verify it fails** -Run: `go test -run TestListAgentsWithFacts -v ./internal/job/client/...` -Expected: FAIL — facts not merged - -**Step 3: Implement facts merging** - -In `internal/job/client/query.go`, modify `ListAgents`: - -```go -func (c *Client) ListAgents( - ctx context.Context, -) ([]job.AgentInfo, error) { - if c.registryKV == nil { - return nil, fmt.Errorf("agent registry not configured") - } - - keys, err := c.registryKV.Keys(ctx) - if err != nil { - if err.Error() == "nats: no keys found" { - return []job.AgentInfo{}, nil - } - return nil, fmt.Errorf("failed to list registry keys: %w", err) - } - - agents := make([]job.AgentInfo, 0, len(keys)) - for _, key := range keys { - entry, err := c.registryKV.Get(ctx, key) - if err != nil { - continue - } - - var reg job.AgentRegistration - if err := json.Unmarshal(entry.Value(), ®); err != nil { - continue - } - - info := agentInfoFromRegistration(®) - c.mergeFacts(ctx, &info) - agents = append(agents, info) - } - - return agents, nil -} +```bash +go test -run TestListAgentsWithFacts -v ./internal/job/client/... ``` -Add `mergeFacts` helper: +**Step 3: Implement mergeFacts** + +Add to `internal/job/client/query.go`: ```go -// mergeFacts reads facts from the facts KV and overlays them onto AgentInfo. -func (c *Client) mergeFacts( - ctx context.Context, - info *job.AgentInfo, -) { +func (c *Client) mergeFacts(ctx context.Context, info *job.AgentInfo) { if c.factsKV == nil { return } @@ -932,7 +567,7 @@ func (c *Client) mergeFacts( key := "facts." + job.SanitizeHostname(info.Hostname) entry, err := c.factsKV.Get(ctx, key) if err != nil { - return // facts not available — not an error + return } var facts job.FactsRegistration @@ -951,259 +586,271 @@ func (c *Client) mergeFacts( } ``` -Also update `GetAgent` to call `c.mergeFacts(ctx, &info)` after building -the AgentInfo from registration. +Call `c.mergeFacts(ctx, &info)` in both `ListAgents` (after +`agentInfoFromRegistration`) and `GetAgent` (after building info). **Step 4: Run tests** -Run: `go test -run TestListAgentsWithFacts -v ./internal/job/client/...` -Expected: PASS - -Run: `go test -v ./internal/job/client/...` -Expected: all tests PASS +```bash +go test -v ./internal/job/client/... +``` **Step 5: Commit** -```bash -git add internal/job/client/query.go internal/job/client/query_public_test.go -git commit -m "feat(job): merge facts KV data into ListAgents and GetAgent" +``` +feat(job): merge facts KV data into ListAgents and GetAgent ``` --- -### Task 7: OpenAPI Spec and API Handler +### Task 8: OpenAPI Spec and API Handler **Files:** -- Modify: `internal/api/agent/gen/api.yaml` — extend AgentInfo schema +- Modify: `internal/api/agent/gen/api.yaml` - Run: `go generate ./internal/api/agent/gen/...` - Modify: `internal/api/agent/agent_list.go` — update `buildAgentInfo` +- Test: `internal/api/agent/agent_list_public_test.go` (or existing test) **Step 1: Extend OpenAPI spec** -In `internal/api/agent/gen/api.yaml`, add new properties to `AgentInfo`: +Add to `AgentInfo` properties in `api.yaml`: ```yaml - AgentInfo: - type: object - properties: - # ... existing properties ... - architecture: - type: string - description: CPU architecture (e.g., "amd64", "arm64"). - example: "amd64" - kernel_version: - type: string - description: OS kernel version. - example: "5.15.0-91-generic" - cpu_count: - type: integer - description: Number of logical CPUs. - example: 4 - fqdn: - type: string - description: Fully qualified domain name. - example: "web-01.example.com" - service_mgr: - type: string - description: Init system (e.g., "systemd"). - example: "systemd" - package_mgr: - type: string - description: Package manager (e.g., "apt", "yum"). - example: "apt" - interfaces: - type: array - items: - $ref: '#/components/schemas/NetworkInterfaceResponse' - description: Network interfaces with addresses. - facts: - type: object - additionalProperties: true - description: Extended facts from pluggable collectors. +architecture: + type: string + description: CPU architecture. + example: "amd64" +kernel_version: + type: string + description: OS kernel version. + example: "5.15.0-91-generic" +cpu_count: + type: integer + description: Number of logical CPUs. + example: 4 +fqdn: + type: string + description: Fully qualified domain name. + example: "web-01.example.com" +service_mgr: + type: string + description: Init system. + example: "systemd" +package_mgr: + type: string + description: Package manager. + example: "apt" +interfaces: + type: array + items: + $ref: '#/components/schemas/NetworkInterfaceResponse' +facts: + type: object + additionalProperties: true + description: Extended facts from additional providers. ``` Add `NetworkInterfaceResponse` schema: ```yaml - NetworkInterfaceResponse: - type: object - description: A network interface with its address. - properties: - name: - type: string - description: Interface name. - example: "eth0" - ipv4: - type: string - description: Primary IPv4 address. - example: "192.168.1.10" - mac: - type: string - description: Hardware (MAC) address. - example: "00:11:22:33:44:55" - required: - - name +NetworkInterfaceResponse: + type: object + properties: + name: + type: string + example: "eth0" + ipv4: + type: string + example: "192.168.1.10" + mac: + type: string + example: "00:11:22:33:44:55" + required: + - name ``` **Step 2: Regenerate** -Run: `go generate ./internal/api/agent/gen/...` -Expected: `agent.gen.go` regenerated with new fields +```bash +go generate ./internal/api/agent/gen/... +``` **Step 3: Update buildAgentInfo** -In `internal/api/agent/agent_list.go`, add mappings after the existing -`MemoryStats` block: +In `internal/api/agent/agent_list.go`, add mappings for new fields after +the existing memory block. Map each non-zero/non-empty field. Map +`Interfaces` as `[]gen.NetworkInterfaceResponse`. -```go -if a.Architecture != "" { - info.Architecture = &a.Architecture -} +Check the generated field names in `agent.gen.go` and match them exactly. -if a.KernelVersion != "" { - info.KernelVersion = &a.KernelVersion -} +**Step 4: Run tests** -if a.CPUCount > 0 { - cpuCount := a.CPUCount - info.CpuCount = &cpuCount -} +```bash +go test -v ./internal/api/agent/... +go build ./... +``` -if a.FQDN != "" { - info.Fqdn = &a.FQDN -} +**Step 5: Commit** -if a.ServiceMgr != "" { - info.ServiceMgr = &a.ServiceMgr -} +``` +feat(api): expose agent facts in AgentInfo responses +``` -if a.PackageMgr != "" { - info.PackageMgr = &a.PackageMgr -} +--- -if len(a.Interfaces) > 0 { - ifaces := make([]gen.NetworkInterfaceResponse, 0, len(a.Interfaces)) - for _, iface := range a.Interfaces { - ni := gen.NetworkInterfaceResponse{Name: iface.Name} - if iface.IPv4 != "" { - ni.Ipv4 = &iface.IPv4 - } - if iface.MAC != "" { - ni.Mac = &iface.MAC - } - ifaces = append(ifaces, ni) - } - info.Interfaces = &ifaces -} +### Task 9: Default Config -if len(a.Facts) > 0 { - facts := gen.AgentInfo_Facts{AdditionalProperties: a.Facts} - info.Facts = &facts -} -``` +**Files:** +- Modify: `osapi.yaml` -> **Note:** The exact generated field names may differ. Check the generated -> `agent.gen.go` after running `go generate` and match the field names. +**Step 1: Add defaults** -**Step 4: Run tests** +Add `nats.facts` section after `nats.registry`: -Run: `go test -v ./internal/api/agent/...` -Expected: PASS +```yaml +facts: + bucket: 'agent-facts' + ttl: '5m' + storage: 'file' + replicas: 1 +``` -**Step 5: Commit** +Add `agent.facts` section after `agent.labels`: + +```yaml +facts: + interval: '60s' +``` + +**Step 2: Verify config loads** ```bash -git add internal/api/agent/gen/api.yaml internal/api/agent/gen/agent.gen.go \ - internal/api/agent/agent_list.go -git commit -m "feat(api): expose agent facts in AgentInfo responses" +go build ./... +``` + +**Step 3: Commit** + +``` +chore: add default facts config to osapi.yaml ``` --- -### Task 8: Default Config and Documentation +### Task 10: Update Documentation — Configuration Reference **Files:** -- Modify: `osapi.yaml` — add `nats.facts` and `agent.facts` sections -- Modify: `docs/docs/sidebar/usage/configuration.md` — document new config +- Modify: `docs/docs/sidebar/usage/configuration.md` -**Step 1: Add defaults to osapi.yaml** +**Step 1: Add environment variable mappings** -```yaml -nats: - # ... existing sections ... - - # ── Facts KV bucket ────────────────────────────────────── - facts: - # KV bucket for agent facts entries. - bucket: 'agent-facts' - # TTL for facts entries (Go duration). Agents refresh - # every 60s; the TTL acts as a staleness timeout. - ttl: '5m' - # Storage backend: "file" or "memory". - storage: 'file' - # Number of KV replicas. - replicas: 1 - -agent: - # ... existing sections ... - - # Fact collection settings. - facts: - # How often to refresh facts (Go duration). - interval: '60s' - # Enabled fact collectors. Built-in: system, hardware, network. - # Phase 2: cloud, local. - collectors: - - system - - hardware - - network +Add to the env var table: + +| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | +| `nats.facts.ttl` | `OSAPI_NATS_FACTS_TTL` | +| `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | +| `nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | +| `agent.facts.interval` | `OSAPI_AGENT_FACTS_INTERVAL` | + +**Step 2: Add section references** + +Add `nats.facts` section reference table (Bucket, TTL, Storage, Replicas). +Add `agent.facts` section reference table (Interval). + +**Step 3: Update full YAML reference** + +Add the `nats.facts` and `agent.facts` blocks to the full reference YAML +with inline comments. + +**Step 4: Commit** + +``` +docs: add facts configuration reference ``` -**Step 2: Update configuration docs** +--- -Add env vars table, section reference, and full reference entries for the -new config sections in `docs/docs/sidebar/usage/configuration.md`. +### Task 11: Update Documentation — Feature and Architecture Pages -**Step 3: Commit** +**Files:** +- Modify: `docs/docs/sidebar/features/node-management.md` +- Modify: `docs/docs/sidebar/architecture/system-architecture.md` +- Modify: `docs/docs/sidebar/architecture/job-architecture.md` -```bash -git add osapi.yaml docs/docs/sidebar/usage/configuration.md -git commit -m "docs: add facts KV and agent facts configuration" +**Step 1: Update node-management.md** + +- In "Agent vs. Node" section, add that agents now expose typed system + facts (architecture, kernel, FQDN, CPU count, network interfaces) in + addition to the basic heartbeat metrics. +- Clarify: facts are gathered every 60s via providers, stored in a + separate `agent-facts` KV bucket with a 5-minute TTL. +- Add a "System Facts" row to the "What It Manages" table. + +**Step 2: Update system-architecture.md** + +- Add `agent-facts` KV bucket to the NATS JetStream section alongside + `agent-registry`. +- Update the component map table to mention facts in the Agent/Provider + layer description. + +**Step 3: Update job-architecture.md** + +- Add a brief section on facts collection: + - Facts are collected independently from the job system. + - 60-second interval, separate KV bucket. + - Providers gather system facts (architecture, kernel, network + interfaces, etc.). + - API merges registry + facts KV into a single AgentInfo response. + +**Step 4: Commit** + +``` +docs: update feature and architecture pages with facts ``` --- -### Task 9: Update agentInfoFromRegistration Helper +### Task 12: Update Documentation — CLI Pages **Files:** -- Modify: `internal/job/client/query.go` — extend `agentInfoFromRegistration` +- Modify: `docs/docs/sidebar/usage/cli/client/agent/list.md` +- Modify: `docs/docs/sidebar/usage/cli/client/agent/get.md` +- Modify: `docs/docs/sidebar/usage/cli/client/health/status.md` -**Step 1: Update the helper** +**Step 1: Update agent list.md** -The existing `agentInfoFromRegistration` maps `AgentRegistration` → `AgentInfo`. -Since `AgentInfo` now has new fields but `AgentRegistration` does not (facts -come from the facts KV), no changes are needed to this function. The merge -happens in `mergeFacts`. +Update the example output to show any new facts-derived columns if the +CLI is updated to display them (e.g., ARCH column). If no CLI column +changes are planned for Phase 1, add a note that `--json` output +includes full facts data. -> Verify that `agentInfoFromRegistration` compiles with the new `AgentInfo` -> fields (they have zero values by default). +**Step 2: Update agent get.md** -**Step 2: Verify build and all tests** +Add facts fields to the example output and field description table: -Run: `go build ./...` -Run: `just go::unit` -Run: `just go::vet` -Expected: all pass +| Architecture | CPU architecture (e.g., amd64) | +| Kernel | OS kernel version | +| FQDN | Fully qualified domain name | +| CPUs | Number of logical CPUs | +| Service Mgr | Init system (e.g., systemd) | +| Package Mgr | Package manager (e.g., apt) | +| Interfaces | Network interfaces with IPv4 and MAC | -**Step 3: Commit (if any changes were needed)** +Update the example output block to show these new fields. + +**Step 3: Update health status.md** + +Add `agent-facts` to the KV buckets section in the example output +(e.g., `Bucket: agent-facts (2 keys, 1.5 KB)`). + +**Step 4: Commit** -```bash -git commit -m "chore: verify agentInfoFromRegistration with new fields" +``` +docs: update CLI docs with agent facts output ``` --- -### Task 10: Final Verification +### Task 13: Final Verification **Step 1: Build** @@ -1229,16 +876,21 @@ just go::vet just go::fmt ``` +**Step 5: Docs format** + +```bash +just docs::fmt-check +``` + All must pass. Fix any issues found. --- ## Out of Scope (Phase 2+) -- Cloud metadata collector (AWS/GCP/Azure) -- Local facts collector (`/etc/osapi/facts.d/`) -- `agent.facts.collectors` config wiring (currently collectors are hardcoded) -- Linux-specific implementations of `detectServiceMgr`, `detectPackageMgr` -- Kernel version from gopsutil `host.KernelVersion()` +- Cloud metadata provider (AWS/GCP/Azure metadata endpoints) +- Local facts provider (`/etc/osapi/facts.d/` JSON/YAML files) +- CLI column changes for `agent list` (facts available via `--json`) - Orchestrator DSL extensions (`Discover`, `WhenFact`, `GroupByFact`) - SDK sync and regeneration +- `Facts map[string]any` population (reserved for Phase 2 providers) From 295b38f3c423932687cdf8ca82e7111c78ec5391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 17:49:03 -0800 Subject: [PATCH 05/24] feat(job): add NetworkInterface and FactsRegistration types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NetworkInterface and FactsRegistration types for the agent facts collection system. Extend AgentInfo with facts fields (architecture, kernel version, CPU count, FQDN, service/package managers, network interfaces, and arbitrary facts map). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/job/types.go | 35 ++++ internal/job/types_public_test.go | 290 ++++++++++++++++++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 internal/job/types_public_test.go diff --git a/internal/job/types.go b/internal/job/types.go index 48be2ce2..ac3a2963 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -249,6 +249,25 @@ type NodeShutdownData struct { Message string `json:"message,omitempty"` } +// NetworkInterface represents a network interface with its address. +type NetworkInterface struct { + Name string `json:"name"` + IPv4 string `json:"ipv4,omitempty"` + MAC string `json:"mac,omitempty"` +} + +// FactsRegistration represents an agent's facts entry in the facts KV bucket. +type FactsRegistration struct { + Architecture string `json:"architecture,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + CPUCount int `json:"cpu_count,omitempty"` + FQDN string `json:"fqdn,omitempty"` + ServiceMgr string `json:"service_mgr,omitempty"` + PackageMgr string `json:"package_mgr,omitempty"` + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + Facts map[string]any `json:"facts,omitempty"` +} + // AgentRegistration represents an agent's registration entry in the KV registry. type AgentRegistration struct { // Hostname is the hostname of the agent. @@ -291,6 +310,22 @@ type AgentInfo struct { MemoryStats *mem.Stats `json:"memory_stats,omitempty"` // AgentVersion is the version of the agent binary. AgentVersion string `json:"agent_version,omitempty"` + // Architecture is the CPU architecture (e.g., x86_64, aarch64). + Architecture string `json:"architecture,omitempty"` + // KernelVersion is the kernel version string. + KernelVersion string `json:"kernel_version,omitempty"` + // CPUCount is the number of logical CPUs. + CPUCount int `json:"cpu_count,omitempty"` + // FQDN is the fully qualified domain name. + FQDN string `json:"fqdn,omitempty"` + // ServiceMgr is the init/service manager (e.g., systemd). + ServiceMgr string `json:"service_mgr,omitempty"` + // PackageMgr is the package manager (e.g., apt, yum). + PackageMgr string `json:"package_mgr,omitempty"` + // Interfaces contains network interface information. + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + // Facts contains arbitrary key-value facts collected by the agent. + Facts map[string]any `json:"facts,omitempty"` } // NodeDiskResponse represents the response for node.disk.get operations. diff --git a/internal/job/types_public_test.go b/internal/job/types_public_test.go new file mode 100644 index 00000000..caacc572 --- /dev/null +++ b/internal/job/types_public_test.go @@ -0,0 +1,290 @@ +// Copyright (c) 2025 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package job_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/host" + "github.com/retr0h/osapi/internal/provider/node/load" + "github.com/retr0h/osapi/internal/provider/node/mem" +) + +type TypesPublicTestSuite struct { + suite.Suite +} + +func (suite *TypesPublicTestSuite) SetupTest() {} + +func (suite *TypesPublicTestSuite) TearDownTest() {} + +func (suite *TypesPublicTestSuite) TestNetworkInterfaceJSONRoundTrip() { + tests := []struct { + name string + iface job.NetworkInterface + validateFunc func(job.NetworkInterface) + }{ + { + name: "when all fields are set", + iface: job.NetworkInterface{ + Name: "eth0", + IPv4: "192.168.1.100", + MAC: "00:1a:2b:3c:4d:5e", + }, + validateFunc: func(result job.NetworkInterface) { + suite.Equal("eth0", result.Name) + suite.Equal("192.168.1.100", result.IPv4) + suite.Equal("00:1a:2b:3c:4d:5e", result.MAC) + }, + }, + { + name: "when only name is set", + iface: job.NetworkInterface{ + Name: "lo", + }, + validateFunc: func(result job.NetworkInterface) { + suite.Equal("lo", result.Name) + suite.Empty(result.IPv4) + suite.Empty(result.MAC) + }, + }, + { + name: "when omitempty fields are absent in JSON", + iface: job.NetworkInterface{ + Name: "wlan0", + IPv4: "10.0.0.1", + }, + validateFunc: func(result job.NetworkInterface) { + suite.Equal("wlan0", result.Name) + suite.Equal("10.0.0.1", result.IPv4) + suite.Empty(result.MAC) + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + data, err := json.Marshal(tt.iface) + suite.NoError(err) + + var result job.NetworkInterface + err = json.Unmarshal(data, &result) + suite.NoError(err) + + tt.validateFunc(result) + }) + } +} + +func (suite *TypesPublicTestSuite) TestFactsRegistrationJSONRoundTrip() { + tests := []struct { + name string + reg job.FactsRegistration + validateFunc func(job.FactsRegistration) + }{ + { + name: "when all fields are set", + reg: job.FactsRegistration{ + Architecture: "x86_64", + KernelVersion: "6.1.0-25-generic", + CPUCount: 8, + FQDN: "web-01.example.com", + ServiceMgr: "systemd", + PackageMgr: "apt", + Interfaces: []job.NetworkInterface{ + { + Name: "eth0", + IPv4: "192.168.1.100", + MAC: "00:1a:2b:3c:4d:5e", + }, + { + Name: "lo", + IPv4: "127.0.0.1", + }, + }, + Facts: map[string]any{ + "custom_key": "custom_value", + "numeric_fact": float64(42), + "bool_fact": true, + "nested_struct": map[string]any{"inner": "value"}, + }, + }, + validateFunc: func(result job.FactsRegistration) { + suite.Equal("x86_64", result.Architecture) + suite.Equal("6.1.0-25-generic", result.KernelVersion) + suite.Equal(8, result.CPUCount) + suite.Equal("web-01.example.com", result.FQDN) + suite.Equal("systemd", result.ServiceMgr) + suite.Equal("apt", result.PackageMgr) + suite.Len(result.Interfaces, 2) + suite.Equal("eth0", result.Interfaces[0].Name) + suite.Equal("192.168.1.100", result.Interfaces[0].IPv4) + suite.Equal("00:1a:2b:3c:4d:5e", result.Interfaces[0].MAC) + suite.Equal("lo", result.Interfaces[1].Name) + suite.Equal("127.0.0.1", result.Interfaces[1].IPv4) + suite.Empty(result.Interfaces[1].MAC) + suite.Equal("custom_value", result.Facts["custom_key"]) + suite.Equal(float64(42), result.Facts["numeric_fact"]) + suite.Equal(true, result.Facts["bool_fact"]) + nested, ok := result.Facts["nested_struct"].(map[string]any) + suite.True(ok) + suite.Equal("value", nested["inner"]) + }, + }, + { + name: "when only required fields are set", + reg: job.FactsRegistration{ + Architecture: "aarch64", + CPUCount: 4, + }, + validateFunc: func(result job.FactsRegistration) { + suite.Equal("aarch64", result.Architecture) + suite.Equal(4, result.CPUCount) + suite.Empty(result.KernelVersion) + suite.Empty(result.FQDN) + suite.Empty(result.ServiceMgr) + suite.Empty(result.PackageMgr) + suite.Nil(result.Interfaces) + suite.Nil(result.Facts) + }, + }, + { + name: "when facts map is empty it is omitted by omitempty", + reg: job.FactsRegistration{ + Architecture: "x86_64", + Facts: map[string]any{}, + }, + validateFunc: func(result job.FactsRegistration) { + suite.Equal("x86_64", result.Architecture) + // Go 1.25 omitempty omits empty maps, so after + // round-trip the field deserializes as nil. + suite.Nil(result.Facts) + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + data, err := json.Marshal(tt.reg) + suite.NoError(err) + + var result job.FactsRegistration + err = json.Unmarshal(data, &result) + suite.NoError(err) + + tt.validateFunc(result) + }) + } +} + +func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { + tests := []struct { + name string + info job.AgentInfo + validateFunc func(job.AgentInfo) + }{ + { + name: "when facts fields are populated", + info: job.AgentInfo{ + Hostname: "web-01", + Labels: map[string]string{"group": "web"}, + RegisteredAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + StartedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + OSInfo: &host.OSInfo{ + Distribution: "Ubuntu", + Version: "22.04", + }, + Uptime: time.Duration(3600) * time.Second, + LoadAverages: &load.AverageStats{Load1: 0.5, Load5: 0.3, Load15: 0.1}, + MemoryStats: &mem.Stats{Total: 1024, Free: 512}, + AgentVersion: "1.0.0", + Architecture: "x86_64", + KernelVersion: "6.1.0-25-generic", + CPUCount: 8, + FQDN: "web-01.example.com", + ServiceMgr: "systemd", + PackageMgr: "apt", + Interfaces: []job.NetworkInterface{ + {Name: "eth0", IPv4: "10.0.0.1", MAC: "aa:bb:cc:dd:ee:ff"}, + }, + Facts: map[string]any{ + "custom": "value", + }, + }, + validateFunc: func(result job.AgentInfo) { + suite.Equal("web-01", result.Hostname) + suite.Equal("1.0.0", result.AgentVersion) + suite.Equal("x86_64", result.Architecture) + suite.Equal("6.1.0-25-generic", result.KernelVersion) + suite.Equal(8, result.CPUCount) + suite.Equal("web-01.example.com", result.FQDN) + suite.Equal("systemd", result.ServiceMgr) + suite.Equal("apt", result.PackageMgr) + suite.Len(result.Interfaces, 1) + suite.Equal("eth0", result.Interfaces[0].Name) + suite.Equal("10.0.0.1", result.Interfaces[0].IPv4) + suite.Equal("aa:bb:cc:dd:ee:ff", result.Interfaces[0].MAC) + suite.Equal("value", result.Facts["custom"]) + }, + }, + { + name: "when facts fields are empty", + info: job.AgentInfo{ + Hostname: "db-01", + AgentVersion: "1.0.0", + }, + validateFunc: func(result job.AgentInfo) { + suite.Equal("db-01", result.Hostname) + suite.Equal("1.0.0", result.AgentVersion) + suite.Empty(result.Architecture) + suite.Empty(result.KernelVersion) + suite.Zero(result.CPUCount) + suite.Empty(result.FQDN) + suite.Empty(result.ServiceMgr) + suite.Empty(result.PackageMgr) + suite.Nil(result.Interfaces) + suite.Nil(result.Facts) + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + data, err := json.Marshal(tt.info) + suite.NoError(err) + + var result job.AgentInfo + err = json.Unmarshal(data, &result) + suite.NoError(err) + + tt.validateFunc(result) + }) + } +} + +func TestTypesPublicTestSuite(t *testing.T) { + suite.Run(t, new(TypesPublicTestSuite)) +} From fafbb848ae468d93442fb22eba5e1f56026e4461 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 17:54:11 -0800 Subject: [PATCH 06/24] feat(config): add NATSFacts and AgentFacts config types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add NATSFacts struct for configuring the agent facts KV bucket (bucket, TTL, storage, replicas) and AgentFacts struct for configuring the facts collection interval. Wire both into the NATS and AgentConfig parent structs. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/config/types.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/internal/config/types.go b/internal/config/types.go index 5638c8dc..61764d7f 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -92,6 +92,7 @@ type NATS struct { DLQ NATSDLQ `mapstructure:"dlq,omitempty"` Audit NATSAudit `mapstructure:"audit,omitempty"` Registry NATSRegistry `mapstructure:"registry,omitempty"` + Facts NATSFacts `mapstructure:"facts,omitempty"` } // NATSAudit configuration for the audit log KV bucket. @@ -113,6 +114,15 @@ type NATSRegistry struct { Replicas int `mapstructure:"replicas"` } +// NATSFacts configuration for the agent facts KV bucket. +type NATSFacts struct { + // Bucket is the KV bucket name for agent facts entries. + Bucket string `mapstructure:"bucket"` + TTL string `mapstructure:"ttl"` // e.g. "1h" + Storage string `mapstructure:"storage"` // "file" or "memory" + Replicas int `mapstructure:"replicas"` +} + // NATSServer configuration settings for the embedded NATS server. type NATSServer struct { // Host the server will bind to. @@ -242,12 +252,20 @@ type AgentConsumer struct { BackOff []string `mapstructure:"back_off"` // e.g. ["30s", "2m", "5m"] } +// AgentFacts configuration for the agent's facts collection settings. +type AgentFacts struct { + // Interval is how often the agent collects and publishes facts. + Interval string `mapstructure:"interval"` // e.g. "5m", "1h" +} + // AgentConfig configuration settings. type AgentConfig struct { // NATS connection settings for the agent. NATS NATSConnection `mapstructure:"nats"` // Consumer settings for the agent's JetStream consumer. Consumer AgentConsumer `mapstructure:"consumer,omitempty"` + // Facts settings for the agent's facts collection. + Facts AgentFacts `mapstructure:"facts,omitempty"` // QueueGroup for load balancing multiple agents. QueueGroup string `mapstructure:"queue_group"` // Hostname identifies this agent instance for routing. From 2a2d7d33f12ab9c6a7542b2fdf89c3558f5b5df8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:01:26 -0800 Subject: [PATCH 07/24] feat(provider): extend host.Provider with fact methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add six new methods to the host.Provider interface for system fact collection: GetArchitecture, GetKernelVersion, GetFQDN, GetCPUCount, GetServiceManager, and GetPackageManager. Implemented for Ubuntu and Darwin providers with testable function variables (HostnameFn, NumCPUFn, StatFn, LookPathFn). Linux provider has stub implementations. All methods follow existing patterns with table-driven tests covering success and error paths. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/provider/node/host/darwin.go | 16 ++- .../node/host/darwin_get_architecture.go | 32 +++++ .../darwin_get_architecture_public_test.go | 98 ++++++++++++++ .../node/host/darwin_get_cpu_count.go | 27 ++++ .../host/darwin_get_cpu_count_public_test.go | 93 +++++++++++++ .../provider/node/host/darwin_get_fqdn.go | 35 +++++ .../node/host/darwin_get_fqdn_public_test.go | 97 ++++++++++++++ .../node/host/darwin_get_kernel_version.go | 31 +++++ .../darwin_get_kernel_version_public_test.go | 98 ++++++++++++++ .../node/host/darwin_get_package_manager.go | 30 +++++ .../darwin_get_package_manager_public_test.go | 96 ++++++++++++++ .../node/host/darwin_get_service_manager.go | 27 ++++ .../darwin_get_service_manager_public_test.go | 73 +++++++++++ .../node/host/linux_get_architecture.go | 31 +++++ .../linux_get_architecture_public_test.go | 64 +++++++++ .../provider/node/host/linux_get_cpu_count.go | 31 +++++ .../host/linux_get_cpu_count_public_test.go | 64 +++++++++ internal/provider/node/host/linux_get_fqdn.go | 31 +++++ .../node/host/linux_get_fqdn_public_test.go | 64 +++++++++ .../node/host/linux_get_kernel_version.go | 31 +++++ .../linux_get_kernel_version_public_test.go | 64 +++++++++ .../node/host/linux_get_package_manager.go | 31 +++++ .../linux_get_package_manager_public_test.go | 64 +++++++++ .../node/host/linux_get_service_manager.go | 31 +++++ .../linux_get_service_manager_public_test.go | 64 +++++++++ internal/provider/node/host/mocks/mocks.go | 7 + .../provider/node/host/mocks/types.gen.go | 90 +++++++++++++ internal/provider/node/host/types.go | 12 ++ internal/provider/node/host/ubuntu.go | 26 +++- .../node/host/ubuntu_get_architecture.go | 32 +++++ .../ubuntu_get_architecture_public_test.go | 98 ++++++++++++++ .../node/host/ubuntu_get_cpu_count.go | 27 ++++ .../host/ubuntu_get_cpu_count_public_test.go | 93 +++++++++++++ .../provider/node/host/ubuntu_get_fqdn.go | 35 +++++ .../node/host/ubuntu_get_fqdn_public_test.go | 97 ++++++++++++++ .../node/host/ubuntu_get_kernel_version.go | 31 +++++ .../ubuntu_get_kernel_version_public_test.go | 98 ++++++++++++++ .../node/host/ubuntu_get_package_manager.go | 35 +++++ .../ubuntu_get_package_manager_public_test.go | 122 ++++++++++++++++++ .../node/host/ubuntu_get_service_manager.go | 32 +++++ .../ubuntu_get_service_manager_public_test.go | 94 ++++++++++++++ 41 files changed, 2248 insertions(+), 4 deletions(-) create mode 100644 internal/provider/node/host/darwin_get_architecture.go create mode 100644 internal/provider/node/host/darwin_get_architecture_public_test.go create mode 100644 internal/provider/node/host/darwin_get_cpu_count.go create mode 100644 internal/provider/node/host/darwin_get_cpu_count_public_test.go create mode 100644 internal/provider/node/host/darwin_get_fqdn.go create mode 100644 internal/provider/node/host/darwin_get_fqdn_public_test.go create mode 100644 internal/provider/node/host/darwin_get_kernel_version.go create mode 100644 internal/provider/node/host/darwin_get_kernel_version_public_test.go create mode 100644 internal/provider/node/host/darwin_get_package_manager.go create mode 100644 internal/provider/node/host/darwin_get_package_manager_public_test.go create mode 100644 internal/provider/node/host/darwin_get_service_manager.go create mode 100644 internal/provider/node/host/darwin_get_service_manager_public_test.go create mode 100644 internal/provider/node/host/linux_get_architecture.go create mode 100644 internal/provider/node/host/linux_get_architecture_public_test.go create mode 100644 internal/provider/node/host/linux_get_cpu_count.go create mode 100644 internal/provider/node/host/linux_get_cpu_count_public_test.go create mode 100644 internal/provider/node/host/linux_get_fqdn.go create mode 100644 internal/provider/node/host/linux_get_fqdn_public_test.go create mode 100644 internal/provider/node/host/linux_get_kernel_version.go create mode 100644 internal/provider/node/host/linux_get_kernel_version_public_test.go create mode 100644 internal/provider/node/host/linux_get_package_manager.go create mode 100644 internal/provider/node/host/linux_get_package_manager_public_test.go create mode 100644 internal/provider/node/host/linux_get_service_manager.go create mode 100644 internal/provider/node/host/linux_get_service_manager_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_architecture.go create mode 100644 internal/provider/node/host/ubuntu_get_architecture_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_cpu_count.go create mode 100644 internal/provider/node/host/ubuntu_get_cpu_count_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_fqdn.go create mode 100644 internal/provider/node/host/ubuntu_get_fqdn_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_kernel_version.go create mode 100644 internal/provider/node/host/ubuntu_get_kernel_version_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_package_manager.go create mode 100644 internal/provider/node/host/ubuntu_get_package_manager_public_test.go create mode 100644 internal/provider/node/host/ubuntu_get_service_manager.go create mode 100644 internal/provider/node/host/ubuntu_get_service_manager_public_test.go diff --git a/internal/provider/node/host/darwin.go b/internal/provider/node/host/darwin.go index 2d367bf2..4ea944d0 100644 --- a/internal/provider/node/host/darwin.go +++ b/internal/provider/node/host/darwin.go @@ -21,17 +21,29 @@ package host import ( + "os" + "os/exec" + "runtime" + "github.com/shirou/gopsutil/v4/host" ) // Darwin implements the Host interface for Darwin (macOS). type Darwin struct { - InfoFn func() (*host.InfoStat, error) + InfoFn func() (*host.InfoStat, error) + HostnameFn func() (string, error) + NumCPUFn func() int + StatFn func(name string) (os.FileInfo, error) + LookPathFn func(file string) (string, error) } // NewDarwinProvider factory to create a new Darwin instance. func NewDarwinProvider() *Darwin { return &Darwin{ - InfoFn: host.Info, + InfoFn: host.Info, + HostnameFn: os.Hostname, + NumCPUFn: runtime.NumCPU, + StatFn: os.Stat, + LookPathFn: exec.LookPath, } } diff --git a/internal/provider/node/host/darwin_get_architecture.go b/internal/provider/node/host/darwin_get_architecture.go new file mode 100644 index 00000000..45074c10 --- /dev/null +++ b/internal/provider/node/host/darwin_get_architecture.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetArchitecture retrieves the system CPU architecture (e.g., x86_64, arm64). +// It uses gopsutil's KernelArch field which returns the native architecture +// as reported by `uname -m`. +func (d *Darwin) GetArchitecture() (string, error) { + hostInfo, err := d.InfoFn() + if err != nil { + return "", err + } + return hostInfo.KernelArch, nil +} diff --git a/internal/provider/node/host/darwin_get_architecture_public_test.go b/internal/provider/node/host/darwin_get_architecture_public_test.go new file mode 100644 index 00000000..89776edf --- /dev/null +++ b/internal/provider/node/host/darwin_get_architecture_public_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + sysHost "github.com/shirou/gopsutil/v4/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetArchitecturePublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetArchitecturePublicTestSuite) SetupTest() {} + +func (suite *DarwinGetArchitecturePublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetArchitecturePublicTestSuite) TestGetArchitecture() { + tests := []struct { + name string + setupMock func() func() (*sysHost.InfoStat, error) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetArchitecture Ok", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return &sysHost.InfoStat{KernelArch: "arm64"}, nil + } + }, + want: "arm64", + wantErr: false, + }, + { + name: "when host.Info errors", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return nil, assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + if tc.setupMock != nil { + darwin.InfoFn = tc.setupMock() + } + + got, err := darwin.GetArchitecture() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetArchitecturePublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetArchitecturePublicTestSuite)) +} diff --git a/internal/provider/node/host/darwin_get_cpu_count.go b/internal/provider/node/host/darwin_get_cpu_count.go new file mode 100644 index 00000000..40897b2d --- /dev/null +++ b/internal/provider/node/host/darwin_get_cpu_count.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetCPUCount retrieves the number of logical CPUs available to the process. +// It uses runtime.NumCPU under the hood. +func (d *Darwin) GetCPUCount() (int, error) { + return d.NumCPUFn(), nil +} diff --git a/internal/provider/node/host/darwin_get_cpu_count_public_test.go b/internal/provider/node/host/darwin_get_cpu_count_public_test.go new file mode 100644 index 00000000..47887c43 --- /dev/null +++ b/internal/provider/node/host/darwin_get_cpu_count_public_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetCPUCountPublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetCPUCountPublicTestSuite) SetupTest() {} + +func (suite *DarwinGetCPUCountPublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetCPUCountPublicTestSuite) TestGetCPUCount() { + tests := []struct { + name string + setupMock func(d *host.Darwin) + want interface{} + wantErr bool + }{ + { + name: "when GetCPUCount Ok", + setupMock: func(d *host.Darwin) { + d.NumCPUFn = func() int { + return 10 + } + }, + want: 10, + wantErr: false, + }, + { + name: "when NumCPU returns 1", + setupMock: func(d *host.Darwin) { + d.NumCPUFn = func() int { + return 1 + } + }, + want: 1, + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + if tc.setupMock != nil { + tc.setupMock(darwin) + } + + got, err := darwin.GetCPUCount() + + if tc.wantErr { + suite.Error(err) + suite.Equal(0, got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetCPUCountPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetCPUCountPublicTestSuite)) +} diff --git a/internal/provider/node/host/darwin_get_fqdn.go b/internal/provider/node/host/darwin_get_fqdn.go new file mode 100644 index 00000000..b09c4339 --- /dev/null +++ b/internal/provider/node/host/darwin_get_fqdn.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetFQDN retrieves the fully qualified domain name of the system. +// It returns the hostname as reported by the operating system. +func (d *Darwin) GetFQDN() (string, error) { + hostname, err := d.HostnameFn() + if err != nil { + return "", fmt.Errorf("failed to get FQDN: %w", err) + } + return hostname, nil +} diff --git a/internal/provider/node/host/darwin_get_fqdn_public_test.go b/internal/provider/node/host/darwin_get_fqdn_public_test.go new file mode 100644 index 00000000..7d490f6c --- /dev/null +++ b/internal/provider/node/host/darwin_get_fqdn_public_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetFQDNPublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetFQDNPublicTestSuite) SetupTest() {} + +func (suite *DarwinGetFQDNPublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetFQDNPublicTestSuite) TestGetFQDN() { + tests := []struct { + name string + setupMock func(d *host.Darwin) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetFQDN Ok", + setupMock: func(d *host.Darwin) { + d.HostnameFn = func() (string, error) { + return "mac-01.local", nil + } + }, + want: "mac-01.local", + wantErr: false, + }, + { + name: "when os.Hostname errors", + setupMock: func(d *host.Darwin) { + d.HostnameFn = func() (string, error) { + return "", assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + if tc.setupMock != nil { + tc.setupMock(darwin) + } + + got, err := darwin.GetFQDN() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetFQDNPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetFQDNPublicTestSuite)) +} diff --git a/internal/provider/node/host/darwin_get_kernel_version.go b/internal/provider/node/host/darwin_get_kernel_version.go new file mode 100644 index 00000000..83381464 --- /dev/null +++ b/internal/provider/node/host/darwin_get_kernel_version.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetKernelVersion retrieves the running kernel version string. +// It uses gopsutil's KernelVersion field. +func (d *Darwin) GetKernelVersion() (string, error) { + hostInfo, err := d.InfoFn() + if err != nil { + return "", err + } + return hostInfo.KernelVersion, nil +} diff --git a/internal/provider/node/host/darwin_get_kernel_version_public_test.go b/internal/provider/node/host/darwin_get_kernel_version_public_test.go new file mode 100644 index 00000000..747f873b --- /dev/null +++ b/internal/provider/node/host/darwin_get_kernel_version_public_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + sysHost "github.com/shirou/gopsutil/v4/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetKernelVersionPublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetKernelVersionPublicTestSuite) SetupTest() {} + +func (suite *DarwinGetKernelVersionPublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetKernelVersionPublicTestSuite) TestGetKernelVersion() { + tests := []struct { + name string + setupMock func() func() (*sysHost.InfoStat, error) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetKernelVersion Ok", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return &sysHost.InfoStat{KernelVersion: "24.3.0"}, nil + } + }, + want: "24.3.0", + wantErr: false, + }, + { + name: "when host.Info errors", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return nil, assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + if tc.setupMock != nil { + darwin.InfoFn = tc.setupMock() + } + + got, err := darwin.GetKernelVersion() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetKernelVersionPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetKernelVersionPublicTestSuite)) +} diff --git a/internal/provider/node/host/darwin_get_package_manager.go b/internal/provider/node/host/darwin_get_package_manager.go new file mode 100644 index 00000000..e036d5da --- /dev/null +++ b/internal/provider/node/host/darwin_get_package_manager.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetPackageManager detects the system's package manager. +// On macOS, it checks for brew. +func (d *Darwin) GetPackageManager() (string, error) { + if _, err := d.LookPathFn("brew"); err == nil { + return "brew", nil + } + return "unknown", nil +} diff --git a/internal/provider/node/host/darwin_get_package_manager_public_test.go b/internal/provider/node/host/darwin_get_package_manager_public_test.go new file mode 100644 index 00000000..dcbd97c1 --- /dev/null +++ b/internal/provider/node/host/darwin_get_package_manager_public_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetPackageManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetPackageManagerPublicTestSuite) SetupTest() {} + +func (suite *DarwinGetPackageManagerPublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetPackageManagerPublicTestSuite) TestGetPackageManager() { + tests := []struct { + name string + setupMock func(d *host.Darwin) + want interface{} + wantErr bool + }{ + { + name: "when brew detected", + setupMock: func(d *host.Darwin) { + d.LookPathFn = func(file string) (string, error) { + if file == "brew" { + return "/opt/homebrew/bin/brew", nil + } + return "", &host.ExecNotFoundError{Name: file} + } + }, + want: "brew", + wantErr: false, + }, + { + name: "when no package manager detected", + setupMock: func(d *host.Darwin) { + d.LookPathFn = func(_ string) (string, error) { + return "", &host.ExecNotFoundError{Name: "unknown"} + } + }, + want: "unknown", + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + if tc.setupMock != nil { + tc.setupMock(darwin) + } + + got, err := darwin.GetPackageManager() + + if tc.wantErr { + suite.Error(err) + suite.Empty(got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetPackageManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetPackageManagerPublicTestSuite)) +} diff --git a/internal/provider/node/host/darwin_get_service_manager.go b/internal/provider/node/host/darwin_get_service_manager.go new file mode 100644 index 00000000..0d22edc8 --- /dev/null +++ b/internal/provider/node/host/darwin_get_service_manager.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetServiceManager detects the system's service manager. +// On macOS, launchd is always the service manager. +func (d *Darwin) GetServiceManager() (string, error) { + return "launchd", nil +} diff --git a/internal/provider/node/host/darwin_get_service_manager_public_test.go b/internal/provider/node/host/darwin_get_service_manager_public_test.go new file mode 100644 index 00000000..198e7daa --- /dev/null +++ b/internal/provider/node/host/darwin_get_service_manager_public_test.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type DarwinGetServiceManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *DarwinGetServiceManagerPublicTestSuite) SetupTest() {} + +func (suite *DarwinGetServiceManagerPublicTestSuite) TearDownTest() {} + +func (suite *DarwinGetServiceManagerPublicTestSuite) TestGetServiceManager() { + tests := []struct { + name string + want interface{} + wantErr bool + }{ + { + name: "when GetServiceManager returns launchd", + want: "launchd", + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + darwin := host.NewDarwinProvider() + + got, err := darwin.GetServiceManager() + + if tc.wantErr { + suite.Error(err) + suite.Empty(got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestDarwinGetServiceManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinGetServiceManagerPublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_architecture.go b/internal/provider/node/host/linux_get_architecture.go new file mode 100644 index 00000000..fd057b8f --- /dev/null +++ b/internal/provider/node/host/linux_get_architecture.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetArchitecture retrieves the system CPU architecture. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetArchitecture() (string, error) { + return "", fmt.Errorf("getArchitecture is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_architecture_public_test.go b/internal/provider/node/host/linux_get_architecture_public_test.go new file mode 100644 index 00000000..7f96bbfb --- /dev/null +++ b/internal/provider/node/host/linux_get_architecture_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetArchitecturePublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetArchitecturePublicTestSuite) SetupTest() {} + +func (suite *LinuxGetArchitecturePublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetArchitecturePublicTestSuite) TestGetArchitecture() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetArchitecture() + + suite.Empty(got) + suite.EqualError(err, "getArchitecture is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetArchitecturePublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetArchitecturePublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_cpu_count.go b/internal/provider/node/host/linux_get_cpu_count.go new file mode 100644 index 00000000..fd47f68a --- /dev/null +++ b/internal/provider/node/host/linux_get_cpu_count.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetCPUCount retrieves the number of logical CPUs available. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetCPUCount() (int, error) { + return 0, fmt.Errorf("getCPUCount is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_cpu_count_public_test.go b/internal/provider/node/host/linux_get_cpu_count_public_test.go new file mode 100644 index 00000000..b70cde4e --- /dev/null +++ b/internal/provider/node/host/linux_get_cpu_count_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetCPUCountPublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetCPUCountPublicTestSuite) SetupTest() {} + +func (suite *LinuxGetCPUCountPublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetCPUCountPublicTestSuite) TestGetCPUCount() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetCPUCount() + + suite.Equal(0, got) + suite.EqualError(err, "getCPUCount is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetCPUCountPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetCPUCountPublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_fqdn.go b/internal/provider/node/host/linux_get_fqdn.go new file mode 100644 index 00000000..7d7b4624 --- /dev/null +++ b/internal/provider/node/host/linux_get_fqdn.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetFQDN retrieves the fully qualified domain name of the system. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetFQDN() (string, error) { + return "", fmt.Errorf("getFQDN is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_fqdn_public_test.go b/internal/provider/node/host/linux_get_fqdn_public_test.go new file mode 100644 index 00000000..27c9dcd0 --- /dev/null +++ b/internal/provider/node/host/linux_get_fqdn_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetFQDNPublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetFQDNPublicTestSuite) SetupTest() {} + +func (suite *LinuxGetFQDNPublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetFQDNPublicTestSuite) TestGetFQDN() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetFQDN() + + suite.Empty(got) + suite.EqualError(err, "getFQDN is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetFQDNPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetFQDNPublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_kernel_version.go b/internal/provider/node/host/linux_get_kernel_version.go new file mode 100644 index 00000000..3c3003d4 --- /dev/null +++ b/internal/provider/node/host/linux_get_kernel_version.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetKernelVersion retrieves the running kernel version string. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetKernelVersion() (string, error) { + return "", fmt.Errorf("getKernelVersion is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_kernel_version_public_test.go b/internal/provider/node/host/linux_get_kernel_version_public_test.go new file mode 100644 index 00000000..5cbcb040 --- /dev/null +++ b/internal/provider/node/host/linux_get_kernel_version_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetKernelVersionPublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetKernelVersionPublicTestSuite) SetupTest() {} + +func (suite *LinuxGetKernelVersionPublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetKernelVersionPublicTestSuite) TestGetKernelVersion() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetKernelVersion() + + suite.Empty(got) + suite.EqualError(err, "getKernelVersion is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetKernelVersionPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetKernelVersionPublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_package_manager.go b/internal/provider/node/host/linux_get_package_manager.go new file mode 100644 index 00000000..dece66c0 --- /dev/null +++ b/internal/provider/node/host/linux_get_package_manager.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetPackageManager detects the system's package manager. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetPackageManager() (string, error) { + return "", fmt.Errorf("getPackageManager is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_package_manager_public_test.go b/internal/provider/node/host/linux_get_package_manager_public_test.go new file mode 100644 index 00000000..3c700a4a --- /dev/null +++ b/internal/provider/node/host/linux_get_package_manager_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetPackageManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetPackageManagerPublicTestSuite) SetupTest() {} + +func (suite *LinuxGetPackageManagerPublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetPackageManagerPublicTestSuite) TestGetPackageManager() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetPackageManager() + + suite.Empty(got) + suite.EqualError(err, "getPackageManager is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetPackageManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetPackageManagerPublicTestSuite)) +} diff --git a/internal/provider/node/host/linux_get_service_manager.go b/internal/provider/node/host/linux_get_service_manager.go new file mode 100644 index 00000000..d2ac5697 --- /dev/null +++ b/internal/provider/node/host/linux_get_service_manager.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetServiceManager detects the system's service manager. +// It returns an error because it is not implemented for LinuxProvider. +func (l *Linux) GetServiceManager() (string, error) { + return "", fmt.Errorf("getServiceManager is not implemented for LinuxProvider") +} diff --git a/internal/provider/node/host/linux_get_service_manager_public_test.go b/internal/provider/node/host/linux_get_service_manager_public_test.go new file mode 100644 index 00000000..473f692e --- /dev/null +++ b/internal/provider/node/host/linux_get_service_manager_public_test.go @@ -0,0 +1,64 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type LinuxGetServiceManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *LinuxGetServiceManagerPublicTestSuite) SetupTest() {} + +func (suite *LinuxGetServiceManagerPublicTestSuite) TearDownTest() {} + +func (suite *LinuxGetServiceManagerPublicTestSuite) TestGetServiceManager() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + linux := host.NewLinuxProvider() + + got, err := linux.GetServiceManager() + + suite.Empty(got) + suite.EqualError(err, "getServiceManager is not implemented for LinuxProvider") + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestLinuxGetServiceManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxGetServiceManagerPublicTestSuite)) +} diff --git a/internal/provider/node/host/mocks/mocks.go b/internal/provider/node/host/mocks/mocks.go index d527b41a..af04fd27 100644 --- a/internal/provider/node/host/mocks/mocks.go +++ b/internal/provider/node/host/mocks/mocks.go @@ -45,5 +45,12 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider { Version: "24.04", }, nil).AnyTimes() + mock.EXPECT().GetArchitecture().Return("amd64", nil).AnyTimes() + mock.EXPECT().GetKernelVersion().Return("5.15.0-91-generic", nil).AnyTimes() + mock.EXPECT().GetFQDN().Return("default-hostname.local", nil).AnyTimes() + mock.EXPECT().GetCPUCount().Return(4, nil).AnyTimes() + mock.EXPECT().GetServiceManager().Return("systemd", nil).AnyTimes() + mock.EXPECT().GetPackageManager().Return("apt", nil).AnyTimes() + return mock } diff --git a/internal/provider/node/host/mocks/types.gen.go b/internal/provider/node/host/mocks/types.gen.go index f2e0fb6d..94719299 100644 --- a/internal/provider/node/host/mocks/types.gen.go +++ b/internal/provider/node/host/mocks/types.gen.go @@ -35,6 +35,51 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } +// GetArchitecture mocks base method. +func (m *MockProvider) GetArchitecture() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetArchitecture") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetArchitecture indicates an expected call of GetArchitecture. +func (mr *MockProviderMockRecorder) GetArchitecture() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetArchitecture", reflect.TypeOf((*MockProvider)(nil).GetArchitecture)) +} + +// GetCPUCount mocks base method. +func (m *MockProvider) GetCPUCount() (int, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetCPUCount") + ret0, _ := ret[0].(int) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetCPUCount indicates an expected call of GetCPUCount. +func (mr *MockProviderMockRecorder) GetCPUCount() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCPUCount", reflect.TypeOf((*MockProvider)(nil).GetCPUCount)) +} + +// GetFQDN mocks base method. +func (m *MockProvider) GetFQDN() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFQDN") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFQDN indicates an expected call of GetFQDN. +func (mr *MockProviderMockRecorder) GetFQDN() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFQDN", reflect.TypeOf((*MockProvider)(nil).GetFQDN)) +} + // GetHostname mocks base method. func (m *MockProvider) GetHostname() (string, error) { m.ctrl.T.Helper() @@ -50,6 +95,21 @@ func (mr *MockProviderMockRecorder) GetHostname() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHostname", reflect.TypeOf((*MockProvider)(nil).GetHostname)) } +// GetKernelVersion mocks base method. +func (m *MockProvider) GetKernelVersion() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetKernelVersion") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetKernelVersion indicates an expected call of GetKernelVersion. +func (mr *MockProviderMockRecorder) GetKernelVersion() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetKernelVersion", reflect.TypeOf((*MockProvider)(nil).GetKernelVersion)) +} + // GetOSInfo mocks base method. func (m *MockProvider) GetOSInfo() (*host.OSInfo, error) { m.ctrl.T.Helper() @@ -65,6 +125,36 @@ func (mr *MockProviderMockRecorder) GetOSInfo() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOSInfo", reflect.TypeOf((*MockProvider)(nil).GetOSInfo)) } +// GetPackageManager mocks base method. +func (m *MockProvider) GetPackageManager() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetPackageManager") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetPackageManager indicates an expected call of GetPackageManager. +func (mr *MockProviderMockRecorder) GetPackageManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPackageManager", reflect.TypeOf((*MockProvider)(nil).GetPackageManager)) +} + +// GetServiceManager mocks base method. +func (m *MockProvider) GetServiceManager() (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetServiceManager") + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceManager indicates an expected call of GetServiceManager. +func (mr *MockProviderMockRecorder) GetServiceManager() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceManager", reflect.TypeOf((*MockProvider)(nil).GetServiceManager)) +} + // GetUptime mocks base method. func (m *MockProvider) GetUptime() (time.Duration, error) { m.ctrl.T.Helper() diff --git a/internal/provider/node/host/types.go b/internal/provider/node/host/types.go index 6b3438cf..f7adc9fa 100644 --- a/internal/provider/node/host/types.go +++ b/internal/provider/node/host/types.go @@ -34,6 +34,18 @@ type Provider interface { // distribution name and version. It returns an OSInfo struct containing this // data and an error if something goes wrong during the process. GetOSInfo() (*OSInfo, error) + // GetArchitecture retrieves the system CPU architecture (e.g., x86_64, arm64). + GetArchitecture() (string, error) + // GetKernelVersion retrieves the running kernel version string. + GetKernelVersion() (string, error) + // GetFQDN retrieves the fully qualified domain name of the system. + GetFQDN() (string, error) + // GetCPUCount retrieves the number of logical CPUs available. + GetCPUCount() (int, error) + // GetServiceManager detects the system's service manager (e.g., systemd). + GetServiceManager() (string, error) + // GetPackageManager detects the system's package manager (e.g., apt, dnf, yum). + GetPackageManager() (string, error) } // OSInfo represents the operating system information. diff --git a/internal/provider/node/host/ubuntu.go b/internal/provider/node/host/ubuntu.go index bc87cea3..bee3b299 100644 --- a/internal/provider/node/host/ubuntu.go +++ b/internal/provider/node/host/ubuntu.go @@ -21,17 +21,39 @@ package host import ( + "os" + "os/exec" + "runtime" + "github.com/shirou/gopsutil/v4/host" ) // Ubuntu implements the Mem interface for Ubuntu. type Ubuntu struct { - InfoFn func() (*host.InfoStat, error) + InfoFn func() (*host.InfoStat, error) + HostnameFn func() (string, error) + NumCPUFn func() int + StatFn func(name string) (os.FileInfo, error) + LookPathFn func(file string) (string, error) } // NewUbuntuProvider factory to create a new Ubuntu instance. func NewUbuntuProvider() *Ubuntu { return &Ubuntu{ - InfoFn: host.Info, + InfoFn: host.Info, + HostnameFn: os.Hostname, + NumCPUFn: runtime.NumCPU, + StatFn: os.Stat, + LookPathFn: exec.LookPath, } } + +// ExecNotFoundError wraps exec.ErrNotFound for testability. +type ExecNotFoundError struct { + Name string +} + +// Error implements the error interface. +func (e *ExecNotFoundError) Error() string { + return "executable file not found: " + e.Name +} diff --git a/internal/provider/node/host/ubuntu_get_architecture.go b/internal/provider/node/host/ubuntu_get_architecture.go new file mode 100644 index 00000000..c6f53e56 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_architecture.go @@ -0,0 +1,32 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetArchitecture retrieves the system CPU architecture (e.g., x86_64, arm64). +// It uses gopsutil's KernelArch field which returns the native architecture +// as reported by `uname -m`. +func (u *Ubuntu) GetArchitecture() (string, error) { + hostInfo, err := u.InfoFn() + if err != nil { + return "", err + } + return hostInfo.KernelArch, nil +} diff --git a/internal/provider/node/host/ubuntu_get_architecture_public_test.go b/internal/provider/node/host/ubuntu_get_architecture_public_test.go new file mode 100644 index 00000000..49b0d5ad --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_architecture_public_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + sysHost "github.com/shirou/gopsutil/v4/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetArchitecturePublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetArchitecturePublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetArchitecturePublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetArchitecturePublicTestSuite) TestGetArchitecture() { + tests := []struct { + name string + setupMock func() func() (*sysHost.InfoStat, error) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetArchitecture Ok", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return &sysHost.InfoStat{KernelArch: "x86_64"}, nil + } + }, + want: "x86_64", + wantErr: false, + }, + { + name: "when host.Info errors", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return nil, assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + ubuntu.InfoFn = tc.setupMock() + } + + got, err := ubuntu.GetArchitecture() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetArchitecturePublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetArchitecturePublicTestSuite)) +} diff --git a/internal/provider/node/host/ubuntu_get_cpu_count.go b/internal/provider/node/host/ubuntu_get_cpu_count.go new file mode 100644 index 00000000..bdb705d2 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_cpu_count.go @@ -0,0 +1,27 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetCPUCount retrieves the number of logical CPUs available to the process. +// It uses runtime.NumCPU under the hood. +func (u *Ubuntu) GetCPUCount() (int, error) { + return u.NumCPUFn(), nil +} diff --git a/internal/provider/node/host/ubuntu_get_cpu_count_public_test.go b/internal/provider/node/host/ubuntu_get_cpu_count_public_test.go new file mode 100644 index 00000000..40045690 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_cpu_count_public_test.go @@ -0,0 +1,93 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetCPUCountPublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetCPUCountPublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetCPUCountPublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetCPUCountPublicTestSuite) TestGetCPUCount() { + tests := []struct { + name string + setupMock func(u *host.Ubuntu) + want interface{} + wantErr bool + }{ + { + name: "when GetCPUCount Ok", + setupMock: func(u *host.Ubuntu) { + u.NumCPUFn = func() int { + return 8 + } + }, + want: 8, + wantErr: false, + }, + { + name: "when NumCPU returns 1", + setupMock: func(u *host.Ubuntu) { + u.NumCPUFn = func() int { + return 1 + } + }, + want: 1, + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + tc.setupMock(ubuntu) + } + + got, err := ubuntu.GetCPUCount() + + if tc.wantErr { + suite.Error(err) + suite.Equal(0, got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetCPUCountPublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetCPUCountPublicTestSuite)) +} diff --git a/internal/provider/node/host/ubuntu_get_fqdn.go b/internal/provider/node/host/ubuntu_get_fqdn.go new file mode 100644 index 00000000..87fa3039 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_fqdn.go @@ -0,0 +1,35 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +import ( + "fmt" +) + +// GetFQDN retrieves the fully qualified domain name of the system. +// It returns the hostname as reported by the operating system. +func (u *Ubuntu) GetFQDN() (string, error) { + hostname, err := u.HostnameFn() + if err != nil { + return "", fmt.Errorf("failed to get FQDN: %w", err) + } + return hostname, nil +} diff --git a/internal/provider/node/host/ubuntu_get_fqdn_public_test.go b/internal/provider/node/host/ubuntu_get_fqdn_public_test.go new file mode 100644 index 00000000..93acae26 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_fqdn_public_test.go @@ -0,0 +1,97 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetFQDNPublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetFQDNPublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetFQDNPublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetFQDNPublicTestSuite) TestGetFQDN() { + tests := []struct { + name string + setupMock func(u *host.Ubuntu) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetFQDN Ok", + setupMock: func(u *host.Ubuntu) { + u.HostnameFn = func() (string, error) { + return "node-01.example.com", nil + } + }, + want: "node-01.example.com", + wantErr: false, + }, + { + name: "when os.Hostname errors", + setupMock: func(u *host.Ubuntu) { + u.HostnameFn = func() (string, error) { + return "", assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + tc.setupMock(ubuntu) + } + + got, err := ubuntu.GetFQDN() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetFQDNPublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetFQDNPublicTestSuite)) +} diff --git a/internal/provider/node/host/ubuntu_get_kernel_version.go b/internal/provider/node/host/ubuntu_get_kernel_version.go new file mode 100644 index 00000000..2ff068b4 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_kernel_version.go @@ -0,0 +1,31 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// GetKernelVersion retrieves the running kernel version string. +// It uses gopsutil's KernelVersion field. +func (u *Ubuntu) GetKernelVersion() (string, error) { + hostInfo, err := u.InfoFn() + if err != nil { + return "", err + } + return hostInfo.KernelVersion, nil +} diff --git a/internal/provider/node/host/ubuntu_get_kernel_version_public_test.go b/internal/provider/node/host/ubuntu_get_kernel_version_public_test.go new file mode 100644 index 00000000..7581bf20 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_kernel_version_public_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + sysHost "github.com/shirou/gopsutil/v4/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetKernelVersionPublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetKernelVersionPublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetKernelVersionPublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetKernelVersionPublicTestSuite) TestGetKernelVersion() { + tests := []struct { + name string + setupMock func() func() (*sysHost.InfoStat, error) + want interface{} + wantErr bool + wantErrType error + }{ + { + name: "when GetKernelVersion Ok", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return &sysHost.InfoStat{KernelVersion: "5.15.0-91-generic"}, nil + } + }, + want: "5.15.0-91-generic", + wantErr: false, + }, + { + name: "when host.Info errors", + setupMock: func() func() (*sysHost.InfoStat, error) { + return func() (*sysHost.InfoStat, error) { + return nil, assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + ubuntu.InfoFn = tc.setupMock() + } + + got, err := ubuntu.GetKernelVersion() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Empty(got) + } else { + suite.NoError(err) + suite.NotNil(got) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetKernelVersionPublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetKernelVersionPublicTestSuite)) +} diff --git a/internal/provider/node/host/ubuntu_get_package_manager.go b/internal/provider/node/host/ubuntu_get_package_manager.go new file mode 100644 index 00000000..e1a9cede --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_package_manager.go @@ -0,0 +1,35 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +// packageManagers is the ordered list of package managers to detect. +var packageManagers = []string{"apt", "dnf", "yum"} + +// GetPackageManager detects the system's package manager by checking for +// known executables in order of preference: apt, dnf, yum. +func (u *Ubuntu) GetPackageManager() (string, error) { + for _, pm := range packageManagers { + if _, err := u.LookPathFn(pm); err == nil { + return pm, nil + } + } + return "unknown", nil +} diff --git a/internal/provider/node/host/ubuntu_get_package_manager_public_test.go b/internal/provider/node/host/ubuntu_get_package_manager_public_test.go new file mode 100644 index 00000000..1c5d88d5 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_package_manager_public_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetPackageManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetPackageManagerPublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetPackageManagerPublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetPackageManagerPublicTestSuite) TestGetPackageManager() { + tests := []struct { + name string + setupMock func(u *host.Ubuntu) + want interface{} + wantErr bool + }{ + { + name: "when apt detected", + setupMock: func(u *host.Ubuntu) { + u.LookPathFn = func(file string) (string, error) { + if file == "apt" { + return "/usr/bin/apt", nil + } + return "", &host.ExecNotFoundError{Name: file} + } + }, + want: "apt", + wantErr: false, + }, + { + name: "when dnf detected", + setupMock: func(u *host.Ubuntu) { + u.LookPathFn = func(file string) (string, error) { + if file == "dnf" { + return "/usr/bin/dnf", nil + } + return "", &host.ExecNotFoundError{Name: file} + } + }, + want: "dnf", + wantErr: false, + }, + { + name: "when yum detected", + setupMock: func(u *host.Ubuntu) { + u.LookPathFn = func(file string) (string, error) { + if file == "yum" { + return "/usr/bin/yum", nil + } + return "", &host.ExecNotFoundError{Name: file} + } + }, + want: "yum", + wantErr: false, + }, + { + name: "when no package manager detected", + setupMock: func(u *host.Ubuntu) { + u.LookPathFn = func(_ string) (string, error) { + return "", &host.ExecNotFoundError{Name: "unknown"} + } + }, + want: "unknown", + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + tc.setupMock(ubuntu) + } + + got, err := ubuntu.GetPackageManager() + + if tc.wantErr { + suite.Error(err) + suite.Empty(got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetPackageManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetPackageManagerPublicTestSuite)) +} diff --git a/internal/provider/node/host/ubuntu_get_service_manager.go b/internal/provider/node/host/ubuntu_get_service_manager.go new file mode 100644 index 00000000..01f51742 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_service_manager.go @@ -0,0 +1,32 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host + +const systemdPath = "/run/systemd/system" + +// GetServiceManager detects the system's service manager. +// It checks for the presence of /run/systemd/system to detect systemd. +func (u *Ubuntu) GetServiceManager() (string, error) { + if _, err := u.StatFn(systemdPath); err == nil { + return "systemd", nil + } + return "unknown", nil +} diff --git a/internal/provider/node/host/ubuntu_get_service_manager_public_test.go b/internal/provider/node/host/ubuntu_get_service_manager_public_test.go new file mode 100644 index 00000000..0b6648f6 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_service_manager_public_test.go @@ -0,0 +1,94 @@ +// Copyright (c) 2024 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package host_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider/node/host" +) + +type UbuntuGetServiceManagerPublicTestSuite struct { + suite.Suite +} + +func (suite *UbuntuGetServiceManagerPublicTestSuite) SetupTest() {} + +func (suite *UbuntuGetServiceManagerPublicTestSuite) TearDownTest() {} + +func (suite *UbuntuGetServiceManagerPublicTestSuite) TestGetServiceManager() { + tests := []struct { + name string + setupMock func(u *host.Ubuntu) + want interface{} + wantErr bool + }{ + { + name: "when systemd detected", + setupMock: func(u *host.Ubuntu) { + u.StatFn = func(name string) (os.FileInfo, error) { + return nil, nil + } + }, + want: "systemd", + wantErr: false, + }, + { + name: "when systemd not detected", + setupMock: func(u *host.Ubuntu) { + u.StatFn = func(name string) (os.FileInfo, error) { + return nil, os.ErrNotExist + } + }, + want: "unknown", + wantErr: false, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + ubuntu := host.NewUbuntuProvider() + + if tc.setupMock != nil { + tc.setupMock(ubuntu) + } + + got, err := ubuntu.GetServiceManager() + + if tc.wantErr { + suite.Error(err) + suite.Empty(got) + } else { + suite.NoError(err) + suite.Equal(tc.want, got) + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestUbuntuGetServiceManagerPublicTestSuite(t *testing.T) { + suite.Run(t, new(UbuntuGetServiceManagerPublicTestSuite)) +} From 32ac20fa0cafb60e983faa5c6784c9ff2731b9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:08:48 -0800 Subject: [PATCH 08/24] feat(provider): add netinfo.Provider for network interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new provider that retrieves non-loopback, up network interfaces with name, IPv4, and MAC address. Uses InterfacesFn struct field for testability, matching the pattern used by the host provider. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../network/netinfo/mocks/generate.go | 24 +++ .../provider/network/netinfo/mocks/mocks.go | 43 ++++++ .../network/netinfo/mocks/types.gen.go | 50 ++++++ internal/provider/network/netinfo/netinfo.go | 76 +++++++++ .../network/netinfo/netinfo_public_test.go | 146 ++++++++++++++++++ internal/provider/network/netinfo/types.go | 30 ++++ 6 files changed, 369 insertions(+) create mode 100644 internal/provider/network/netinfo/mocks/generate.go create mode 100644 internal/provider/network/netinfo/mocks/mocks.go create mode 100644 internal/provider/network/netinfo/mocks/types.gen.go create mode 100644 internal/provider/network/netinfo/netinfo.go create mode 100644 internal/provider/network/netinfo/netinfo_public_test.go create mode 100644 internal/provider/network/netinfo/types.go diff --git a/internal/provider/network/netinfo/mocks/generate.go b/internal/provider/network/netinfo/mocks/generate.go new file mode 100644 index 00000000..fb0a0384 --- /dev/null +++ b/internal/provider/network/netinfo/mocks/generate.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// Package mocks provides mock implementations for testing. +package mocks + +//go:generate go tool github.com/golang/mock/mockgen -source=../types.go -destination=types.gen.go -package=mocks diff --git a/internal/provider/network/netinfo/mocks/mocks.go b/internal/provider/network/netinfo/mocks/mocks.go new file mode 100644 index 00000000..65b8566a --- /dev/null +++ b/internal/provider/network/netinfo/mocks/mocks.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package mocks + +import ( + "github.com/golang/mock/gomock" + + "github.com/retr0h/osapi/internal/job" +) + +// NewPlainMockProvider creates a Mock without defaults. +func NewPlainMockProvider(ctrl *gomock.Controller) *MockProvider { + return NewMockProvider(ctrl) +} + +// NewDefaultMockProvider creates a Mock with defaults. +func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider { + mock := NewMockProvider(ctrl) + + mock.EXPECT().GetInterfaces().Return([]job.NetworkInterface{ + {Name: "eth0", IPv4: "192.168.1.10", MAC: "00:11:22:33:44:55"}, + }, nil).AnyTimes() + + return mock +} diff --git a/internal/provider/network/netinfo/mocks/types.gen.go b/internal/provider/network/netinfo/mocks/types.gen.go new file mode 100644 index 00000000..ed0372b6 --- /dev/null +++ b/internal/provider/network/netinfo/mocks/types.gen.go @@ -0,0 +1,50 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ../types.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + job "github.com/retr0h/osapi/internal/job" +) + +// MockProvider is a mock of Provider interface. +type MockProvider struct { + ctrl *gomock.Controller + recorder *MockProviderMockRecorder +} + +// MockProviderMockRecorder is the mock recorder for MockProvider. +type MockProviderMockRecorder struct { + mock *MockProvider +} + +// NewMockProvider creates a new mock instance. +func NewMockProvider(ctrl *gomock.Controller) *MockProvider { + mock := &MockProvider{ctrl: ctrl} + mock.recorder = &MockProviderMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockProvider) EXPECT() *MockProviderMockRecorder { + return m.recorder +} + +// GetInterfaces mocks base method. +func (m *MockProvider) GetInterfaces() ([]job.NetworkInterface, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetInterfaces") + ret0, _ := ret[0].([]job.NetworkInterface) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetInterfaces indicates an expected call of GetInterfaces. +func (mr *MockProviderMockRecorder) GetInterfaces() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetInterfaces", reflect.TypeOf((*MockProvider)(nil).GetInterfaces)) +} diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go new file mode 100644 index 00000000..66273db3 --- /dev/null +++ b/internal/provider/network/netinfo/netinfo.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package netinfo + +import ( + "net" + + "github.com/retr0h/osapi/internal/job" +) + +// Netinfo implements the Provider interface for network interface information. +type Netinfo struct { + InterfacesFn func() ([]net.Interface, error) +} + +// New factory to create a new Netinfo instance. +func New() *Netinfo { + return &Netinfo{ + InterfacesFn: net.Interfaces, + } +} + +// GetInterfaces retrieves non-loopback, up network interfaces +// with name, IPv4, and MAC address. +func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { + ifaces, err := n.InterfacesFn() + if err != nil { + return nil, err + } + + var result []job.NetworkInterface + + for _, iface := range ifaces { + if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 { + continue + } + + ni := job.NetworkInterface{ + Name: iface.Name, + MAC: iface.HardwareAddr.String(), + } + + addrs, err := iface.Addrs() + if err == nil { + for _, addr := range addrs { + if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { + ni.IPv4 = ipNet.IP.String() + + break + } + } + } + + result = append(result, ni) + } + + return result, nil +} diff --git a/internal/provider/network/netinfo/netinfo_public_test.go b/internal/provider/network/netinfo/netinfo_public_test.go new file mode 100644 index 00000000..8df5b513 --- /dev/null +++ b/internal/provider/network/netinfo/netinfo_public_test.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package netinfo_test + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/network/netinfo" +) + +type GetInterfacesPublicTestSuite struct { + suite.Suite +} + +func (suite *GetInterfacesPublicTestSuite) SetupTest() {} + +func (suite *GetInterfacesPublicTestSuite) TearDownTest() {} + +func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() { + tests := []struct { + name string + setupMock func() func() ([]net.Interface, error) + wantErr bool + wantErrType error + validateFunc func(result []job.NetworkInterface) + }{ + { + name: "when GetInterfaces Ok", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 1, + MTU: 65536, + Name: "lo", + HardwareAddr: nil, + Flags: net.FlagUp | net.FlagLoopback, + }, + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast | net.FlagMulticast, + }, + { + Index: 3, + MTU: 1500, + Name: "eth1", + HardwareAddr: net.HardwareAddr{0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF}, + Flags: net.FlagBroadcast | net.FlagMulticast, // not up + }, + }, nil + } + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Equal("eth0", result[0].Name) + suite.Equal("00:11:22:33:44:55", result[0].MAC) + }, + }, + { + name: "when no non-loopback interfaces exist", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 1, + MTU: 65536, + Name: "lo", + Flags: net.FlagUp | net.FlagLoopback, + }, + }, nil + } + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Empty(result) + }, + }, + { + name: "when net.Interfaces errors", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return nil, assert.AnError + } + }, + wantErr: true, + wantErrType: assert.AnError, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + n := netinfo.New() + + if tc.setupMock != nil { + n.InterfacesFn = tc.setupMock() + } + + got, err := n.GetInterfaces() + + if tc.wantErr { + suite.Error(err) + suite.ErrorContains(err, tc.wantErrType.Error()) + suite.Nil(got) + } else { + suite.NoError(err) + + if tc.validateFunc != nil { + tc.validateFunc(got) + } + } + }) + } +} + +// In order for `go test` to run this suite, we need to create +// a normal test function and pass our suite to suite.Run. +func TestGetInterfacesPublicTestSuite(t *testing.T) { + suite.Run(t, new(GetInterfacesPublicTestSuite)) +} diff --git a/internal/provider/network/netinfo/types.go b/internal/provider/network/netinfo/types.go new file mode 100644 index 00000000..172a7c73 --- /dev/null +++ b/internal/provider/network/netinfo/types.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package netinfo + +import "github.com/retr0h/osapi/internal/job" + +// Provider implements the methods to interact with network interface information. +type Provider interface { + // GetInterfaces retrieves non-loopback, up network interfaces + // with name, IPv4, and MAC address. + GetInterfaces() ([]job.NetworkInterface, error) +} From 4e58b11b7aa81805d7c4bbbc638257f8453f5877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:12:56 -0800 Subject: [PATCH 09/24] feat(nats): add facts KV bucket infrastructure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire up the NATS KV bucket for storing agent facts. Add BuildFactsKVConfig helper, create the bucket in setupJetStream, pass factsKV through natsBundle and job client Options, and include it in the health metrics KV bucket list. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/api_helpers.go | 17 ++++++++- cmd/nats_helpers.go | 8 ++++ internal/cli/nats.go | 16 ++++++++ internal/cli/nats_public_test.go | 63 ++++++++++++++++++++++++++++++++ internal/job/client/client.go | 4 ++ 5 files changed, 106 insertions(+), 2 deletions(-) diff --git a/cmd/api_helpers.go b/cmd/api_helpers.go index 9f1fefdb..c07f2811 100644 --- a/cmd/api_helpers.go +++ b/cmd/api_helpers.go @@ -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, @@ -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...) @@ -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 { @@ -168,6 +179,7 @@ func connectNATSBundle( jobClient: jc, jobsKV: jobsKV, registryKV: registryKV, + factsKV: factsKV, } } @@ -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, @@ -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 { diff --git a/cmd/nats_helpers.go b/cmd/nats_helpers.go index 4ef477f7..1f189efc 100644 --- a/cmd/nats_helpers.go +++ b/cmd/nats_helpers.go @@ -164,6 +164,14 @@ func setupJetStream( } } + // Create facts KV bucket with configured settings + if appConfig.NATS.Facts.Bucket != "" { + factsKVConfig := cli.BuildFactsKVConfig(namespace, appConfig.NATS.Facts) + if _, err := nc.CreateOrUpdateKVBucketWithConfig(ctx, factsKVConfig); err != nil { + return fmt.Errorf("create facts KV bucket %s: %w", factsKVConfig.Bucket, err) + } + } + // Create DLQ stream dlqMaxAge, _ := time.ParseDuration(appConfig.NATS.DLQ.MaxAge) dlqStorage := cli.ParseJetstreamStorageType(appConfig.NATS.DLQ.Storage) diff --git a/internal/cli/nats.go b/internal/cli/nats.go index 5c0fe73b..419d1543 100644 --- a/internal/cli/nats.go +++ b/internal/cli/nats.go @@ -91,6 +91,22 @@ func BuildRegistryKVConfig( } } +// BuildFactsKVConfig builds a jetstream.KeyValueConfig from facts config values. +func BuildFactsKVConfig( + namespace string, + factsCfg config.NATSFacts, +) jetstream.KeyValueConfig { + factsBucket := job.ApplyNamespaceToInfraName(namespace, factsCfg.Bucket) + factsTTL, _ := time.ParseDuration(factsCfg.TTL) + + return jetstream.KeyValueConfig{ + Bucket: factsBucket, + TTL: factsTTL, + Storage: ParseJetstreamStorageType(factsCfg.Storage), + Replicas: factsCfg.Replicas, + } +} + // BuildAuditKVConfig builds a jetstream.KeyValueConfig from audit config values. func BuildAuditKVConfig( namespace string, diff --git a/internal/cli/nats_public_test.go b/internal/cli/nats_public_test.go index df29fd81..d92f12f8 100644 --- a/internal/cli/nats_public_test.go +++ b/internal/cli/nats_public_test.go @@ -222,6 +222,69 @@ func (suite *NATSPublicTestSuite) TestBuildRegistryKVConfig() { } } +func (suite *NATSPublicTestSuite) TestBuildFactsKVConfig() { + tests := []struct { + name string + namespace string + factsCfg config.NATSFacts + validateFn func(jetstream.KeyValueConfig) + }{ + { + name: "when namespace is set", + namespace: "osapi", + factsCfg: config.NATSFacts{ + Bucket: "agent-facts", + TTL: "1h", + Storage: "file", + Replicas: 1, + }, + validateFn: func(cfg jetstream.KeyValueConfig) { + assert.Equal(suite.T(), "osapi-agent-facts", cfg.Bucket) + assert.Equal(suite.T(), 1*time.Hour, cfg.TTL) + assert.Equal(suite.T(), jetstream.FileStorage, cfg.Storage) + assert.Equal(suite.T(), 1, cfg.Replicas) + }, + }, + { + name: "when namespace is empty", + namespace: "", + factsCfg: config.NATSFacts{ + Bucket: "agent-facts", + TTL: "30m", + Storage: "memory", + Replicas: 3, + }, + validateFn: func(cfg jetstream.KeyValueConfig) { + assert.Equal(suite.T(), "agent-facts", cfg.Bucket) + assert.Equal(suite.T(), 30*time.Minute, cfg.TTL) + assert.Equal(suite.T(), jetstream.MemoryStorage, cfg.Storage) + assert.Equal(suite.T(), 3, cfg.Replicas) + }, + }, + { + name: "when TTL is invalid defaults to zero", + namespace: "", + factsCfg: config.NATSFacts{ + Bucket: "agent-facts", + TTL: "invalid", + Storage: "file", + Replicas: 1, + }, + validateFn: func(cfg jetstream.KeyValueConfig) { + assert.Equal(suite.T(), time.Duration(0), cfg.TTL) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got := cli.BuildFactsKVConfig(tc.namespace, tc.factsCfg) + + tc.validateFn(got) + }) + } +} + func (suite *NATSPublicTestSuite) TestBuildAuditKVConfig() { tests := []struct { name string diff --git a/internal/job/client/client.go b/internal/job/client/client.go index db700237..da57a3b7 100644 --- a/internal/job/client/client.go +++ b/internal/job/client/client.go @@ -41,6 +41,7 @@ type Client struct { natsClient messaging.NATSClient kv jetstream.KeyValue registryKV jetstream.KeyValue + factsKV jetstream.KeyValue timeout time.Duration streamName string } @@ -53,6 +54,8 @@ type Options struct { KVBucket jetstream.KeyValue // RegistryKV is the KV bucket for agent registry (optional). RegistryKV jetstream.KeyValue + // FactsKV is the KV bucket for agent facts (optional). + FactsKV jetstream.KeyValue // StreamName is the JetStream stream name (used to derive DLQ name). StreamName string } @@ -75,6 +78,7 @@ func New( natsClient: natsClient, kv: opts.KVBucket, registryKV: opts.RegistryKV, + factsKV: opts.FactsKV, streamName: opts.StreamName, timeout: opts.Timeout, }, nil From 4e8271b3b69dfa9cb34233ee7ea9f65820cd3554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:17:22 -0800 Subject: [PATCH 10/24] feat(provider): add IPv6 support to NetworkInterface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add IPv6 field to NetworkInterface type and update netinfo provider to extract both IPv4 and IPv6 addresses from network interfaces. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/plans/2026-03-03-agent-facts-design.md | 2 +- internal/job/types.go | 1 + internal/job/types_public_test.go | 6 +++++- internal/provider/network/netinfo/mocks/mocks.go | 2 +- internal/provider/network/netinfo/netinfo.go | 11 ++++++++--- 5 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/plans/2026-03-03-agent-facts-design.md b/docs/plans/2026-03-03-agent-facts-design.md index 328c2697..bbd470e4 100644 --- a/docs/plans/2026-03-03-agent-facts-design.md +++ b/docs/plans/2026-03-03-agent-facts-design.md @@ -22,7 +22,7 @@ cloud region). |----------|-------|--------| | System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `host.Provider` extensions | | Hardware | cpu_count | `host.Provider` extension | -| Network | interfaces (name, ipv4, mac) | New `netinfo.Provider` | +| Network | interfaces (name, ipv4, ipv6, mac) | New `netinfo.Provider` | **Phase 2 — Additional providers (opt-in):** diff --git a/internal/job/types.go b/internal/job/types.go index ac3a2963..ea620fdf 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -253,6 +253,7 @@ type NodeShutdownData struct { type NetworkInterface struct { Name string `json:"name"` IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` MAC string `json:"mac,omitempty"` } diff --git a/internal/job/types_public_test.go b/internal/job/types_public_test.go index caacc572..f582c4d2 100644 --- a/internal/job/types_public_test.go +++ b/internal/job/types_public_test.go @@ -52,11 +52,13 @@ func (suite *TypesPublicTestSuite) TestNetworkInterfaceJSONRoundTrip() { iface: job.NetworkInterface{ Name: "eth0", IPv4: "192.168.1.100", + IPv6: "fe80::1", MAC: "00:1a:2b:3c:4d:5e", }, validateFunc: func(result job.NetworkInterface) { suite.Equal("eth0", result.Name) suite.Equal("192.168.1.100", result.IPv4) + suite.Equal("fe80::1", result.IPv6) suite.Equal("00:1a:2b:3c:4d:5e", result.MAC) }, }, @@ -68,6 +70,7 @@ func (suite *TypesPublicTestSuite) TestNetworkInterfaceJSONRoundTrip() { validateFunc: func(result job.NetworkInterface) { suite.Equal("lo", result.Name) suite.Empty(result.IPv4) + suite.Empty(result.IPv6) suite.Empty(result.MAC) }, }, @@ -228,7 +231,7 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { ServiceMgr: "systemd", PackageMgr: "apt", Interfaces: []job.NetworkInterface{ - {Name: "eth0", IPv4: "10.0.0.1", MAC: "aa:bb:cc:dd:ee:ff"}, + {Name: "eth0", IPv4: "10.0.0.1", IPv6: "fe80::1", MAC: "aa:bb:cc:dd:ee:ff"}, }, Facts: map[string]any{ "custom": "value", @@ -246,6 +249,7 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { suite.Len(result.Interfaces, 1) suite.Equal("eth0", result.Interfaces[0].Name) suite.Equal("10.0.0.1", result.Interfaces[0].IPv4) + suite.Equal("fe80::1", result.Interfaces[0].IPv6) suite.Equal("aa:bb:cc:dd:ee:ff", result.Interfaces[0].MAC) suite.Equal("value", result.Facts["custom"]) }, diff --git a/internal/provider/network/netinfo/mocks/mocks.go b/internal/provider/network/netinfo/mocks/mocks.go index 65b8566a..c69941b3 100644 --- a/internal/provider/network/netinfo/mocks/mocks.go +++ b/internal/provider/network/netinfo/mocks/mocks.go @@ -36,7 +36,7 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider { mock := NewMockProvider(ctrl) mock.EXPECT().GetInterfaces().Return([]job.NetworkInterface{ - {Name: "eth0", IPv4: "192.168.1.10", MAC: "00:11:22:33:44:55"}, + {Name: "eth0", IPv4: "192.168.1.10", IPv6: "fe80::1", MAC: "00:11:22:33:44:55"}, }, nil).AnyTimes() return mock diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go index 66273db3..956e1c86 100644 --- a/internal/provider/network/netinfo/netinfo.go +++ b/internal/provider/network/netinfo/netinfo.go @@ -61,10 +61,15 @@ func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { addrs, err := iface.Addrs() if err == nil { for _, addr := range addrs { - if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.To4() != nil { - ni.IPv4 = ipNet.IP.String() + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } - break + if ipNet.IP.To4() != nil && ni.IPv4 == "" { + ni.IPv4 = ipNet.IP.String() + } else if ipNet.IP.To4() == nil && ni.IPv6 == "" { + ni.IPv6 = ipNet.IP.String() } } } From 2b328c3cd8ddd9beff2da8c9ba2ffdd6b428939e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:24:14 -0800 Subject: [PATCH 11/24] feat(agent): add facts writer with provider-based collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add facts writer that runs alongside the heartbeat in the agent, collecting system facts via host and netinfo providers and writing them to the agent-facts KV bucket on a 60-second interval. - Create facts.go with startFacts/writeFacts/factsKey functions - Add factsKV and netinfoProvider fields to Agent struct - Update New() constructor and CreateProviders() factory - Wire startFacts() into Start() lifecycle - Pass factsKV and netinfo provider from cmd/agent_helpers.go - Update all existing test files for new constructor signature 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/agent_helpers.go | 4 +- internal/agent/agent.go | 5 + internal/agent/agent_public_test.go | 7 + internal/agent/consumer_test.go | 4 + internal/agent/factory.go | 7 +- internal/agent/factory_public_test.go | 3 +- internal/agent/factory_test.go | 3 +- internal/agent/facts.go | 128 +++++++++++ internal/agent/facts_test.go | 271 +++++++++++++++++++++++ internal/agent/handler_test.go | 4 + internal/agent/heartbeat_public_test.go | 5 + internal/agent/heartbeat_test.go | 3 + internal/agent/processor_command_test.go | 3 + internal/agent/processor_test.go | 22 ++ internal/agent/server.go | 3 + internal/agent/types.go | 7 + 16 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 internal/agent/facts.go create mode 100644 internal/agent/facts_test.go diff --git a/cmd/agent_helpers.go b/cmd/agent_helpers.go index 8b1edaf5..2e632a03 100644 --- a/cmd/agent_helpers.go +++ b/cmd/agent_helpers.go @@ -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, @@ -59,8 +59,10 @@ func setupAgent( loadProvider, dnsProvider, pingProvider, + netinfoProvider, commandProvider, b.registryKV, + b.factsKV, ) return a, b diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 70f204b6..5aa73330 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -30,6 +30,7 @@ import ( "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider/command" "github.com/retr0h/osapi/internal/provider/network/dns" + "github.com/retr0h/osapi/internal/provider/network/netinfo" "github.com/retr0h/osapi/internal/provider/network/ping" "github.com/retr0h/osapi/internal/provider/node/disk" "github.com/retr0h/osapi/internal/provider/node/host" @@ -50,8 +51,10 @@ func New( loadProvider load.Provider, dnsProvider dns.Provider, pingProvider ping.Provider, + netinfoProvider netinfo.Provider, commandProvider command.Provider, registryKV jetstream.KeyValue, + factsKV jetstream.KeyValue, ) *Agent { return &Agent{ logger: logger, @@ -65,7 +68,9 @@ func New( loadProvider: loadProvider, dnsProvider: dnsProvider, pingProvider: pingProvider, + netinfoProvider: netinfoProvider, commandProvider: commandProvider, registryKV: registryKV, + factsKV: factsKV, } } diff --git a/internal/agent/agent_public_test.go b/internal/agent/agent_public_test.go index 68e57022..055cf616 100644 --- a/internal/agent/agent_public_test.go +++ b/internal/agent/agent_public_test.go @@ -35,6 +35,7 @@ import ( "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" @@ -104,8 +105,10 @@ func (s *AgentPublicTestSuite) TestNew() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), nil, + nil, ) s.NotNil(a) @@ -144,8 +147,10 @@ func (s *AgentPublicTestSuite) TestStart() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), nil, + nil, ) }, stopFunc: func(a *agent.Agent) { @@ -191,8 +196,10 @@ func (s *AgentPublicTestSuite) TestStart() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), nil, + nil, ) // Schedule cleanup after Stop returns diff --git a/internal/agent/consumer_test.go b/internal/agent/consumer_test.go index ab74bcb1..038c95e4 100644 --- a/internal/agent/consumer_test.go +++ b/internal/agent/consumer_test.go @@ -37,6 +37,7 @@ import ( "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" @@ -84,6 +85,7 @@ func (s *ConsumerTestSuite) SetupTest() { loadMock := loadMocks.NewDefaultMockProvider(s.mockCtrl) dnsMock := dnsMocks.NewDefaultMockProvider(s.mockCtrl) pingMock := pingMocks.NewDefaultMockProvider(s.mockCtrl) + netinfoMock := netinfoMocks.NewDefaultMockProvider(s.mockCtrl) commandMock := commandMocks.NewDefaultMockProvider(s.mockCtrl) s.agent = New( @@ -98,8 +100,10 @@ func (s *ConsumerTestSuite) SetupTest() { loadMock, dnsMock, pingMock, + netinfoMock, commandMock, nil, + nil, ) } diff --git a/internal/agent/factory.go b/internal/agent/factory.go index aac0bf55..f0028150 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -29,6 +29,7 @@ import ( "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/provider/command" "github.com/retr0h/osapi/internal/provider/network/dns" + "github.com/retr0h/osapi/internal/provider/network/netinfo" "github.com/retr0h/osapi/internal/provider/network/ping" "github.com/retr0h/osapi/internal/provider/node/disk" nodeHost "github.com/retr0h/osapi/internal/provider/node/host" @@ -61,6 +62,7 @@ func (f *ProviderFactory) CreateProviders() ( load.Provider, dns.Provider, ping.Provider, + netinfo.Provider, command.Provider, ) { info, _ := factoryHostInfoFn() @@ -138,8 +140,11 @@ func (f *ProviderFactory) CreateProviders() ( pingProvider = ping.NewLinuxProvider() } + // Create network info provider + netinfoProvider := netinfo.New() + // Create command provider (cross-platform, uses exec.Manager) commandProvider := command.New(f.logger, execManager) - return hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, commandProvider + return hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, netinfoProvider, commandProvider } diff --git a/internal/agent/factory_public_test.go b/internal/agent/factory_public_test.go index e6032375..fb4b76b4 100644 --- a/internal/agent/factory_public_test.go +++ b/internal/agent/factory_public_test.go @@ -64,7 +64,7 @@ func (s *FactoryPublicTestSuite) TestCreateProviders() { s.Run(tt.name, func() { factory := agent.NewProviderFactory(slog.Default()) - hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, commandProvider := factory.CreateProviders() + hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, netinfoProvider, commandProvider := factory.CreateProviders() s.NotNil(hostProvider) s.NotNil(diskProvider) @@ -72,6 +72,7 @@ func (s *FactoryPublicTestSuite) TestCreateProviders() { s.NotNil(loadProvider) s.NotNil(dnsProvider) s.NotNil(pingProvider) + s.NotNil(netinfoProvider) s.NotNil(commandProvider) }) } diff --git a/internal/agent/factory_test.go b/internal/agent/factory_test.go index 3815e863..4c515605 100644 --- a/internal/agent/factory_test.go +++ b/internal/agent/factory_test.go @@ -78,7 +78,7 @@ func (s *FactoryTestSuite) TestCreateProviders() { factoryHostInfoFn = tt.setupMock() factory := NewProviderFactory(slog.Default()) - hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, commandProvider := factory.CreateProviders() + hostProvider, diskProvider, memProvider, loadProvider, dnsProvider, pingProvider, netinfoProvider, commandProvider := factory.CreateProviders() s.NotNil(hostProvider) s.NotNil(diskProvider) @@ -86,6 +86,7 @@ func (s *FactoryTestSuite) TestCreateProviders() { s.NotNil(loadProvider) s.NotNil(dnsProvider) s.NotNil(pingProvider) + s.NotNil(netinfoProvider) s.NotNil(commandProvider) }) } diff --git a/internal/agent/facts.go b/internal/agent/facts.go new file mode 100644 index 00000000..d0642479 --- /dev/null +++ b/internal/agent/facts.go @@ -0,0 +1,128 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "context" + "log/slog" + "time" + + "github.com/retr0h/osapi/internal/job" +) + +// factsInterval controls the fact refresh period. +var factsInterval = 60 * time.Second + +// startFacts writes the initial facts, spawns a goroutine that +// refreshes the entry on a ticker, and stops on ctx.Done(). +func (a *Agent) startFacts( + ctx context.Context, + hostname string, +) { + if a.factsKV == nil { + return + } + + a.writeFacts(ctx, hostname) + + a.wg.Add(1) + go func() { + defer a.wg.Done() + + ticker := time.NewTicker(factsInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + a.writeFacts(ctx, hostname) + } + } + }() +} + +// writeFacts collects system facts from providers and writes them to the +// facts KV bucket. Provider errors are non-fatal; the facts entry is still +// written with whatever data was gathered. +func (a *Agent) writeFacts( + ctx context.Context, + hostname string, +) { + reg := job.FactsRegistration{} + + // Call providers — errors are non-fatal + if arch, err := a.hostProvider.GetArchitecture(); err == nil { + reg.Architecture = arch + } + + if kv, err := a.hostProvider.GetKernelVersion(); err == nil { + reg.KernelVersion = kv + } + + if fqdn, err := a.hostProvider.GetFQDN(); err == nil { + reg.FQDN = fqdn + } + + if count, err := a.hostProvider.GetCPUCount(); err == nil { + reg.CPUCount = count + } + + if mgr, err := a.hostProvider.GetServiceManager(); err == nil { + reg.ServiceMgr = mgr + } + + if mgr, err := a.hostProvider.GetPackageManager(); err == nil { + reg.PackageMgr = mgr + } + + if ifaces, err := a.netinfoProvider.GetInterfaces(); err == nil { + reg.Interfaces = ifaces + } + + data, err := marshalJSON(reg) + if err != nil { + a.logger.Warn( + "failed to marshal facts", + slog.String("hostname", hostname), + slog.String("error", err.Error()), + ) + return + } + + key := factsKey(hostname) + if _, err := a.factsKV.Put(ctx, key, data); err != nil { + a.logger.Warn( + "failed to write facts", + slog.String("hostname", hostname), + slog.String("key", key), + slog.String("error", err.Error()), + ) + } +} + +// factsKey returns the KV key for an agent's facts entry. +func factsKey( + hostname string, +) string { + return "facts." + job.SanitizeHostname(hostname) +} diff --git a/internal/agent/facts_test.go b/internal/agent/facts_test.go new file mode 100644 index 00000000..3366469e --- /dev/null +++ b/internal/agent/facts_test.go @@ -0,0 +1,271 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package agent + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/spf13/afero" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/job/mocks" + commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" + dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" + pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" + diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" + hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" + loadMocks "github.com/retr0h/osapi/internal/provider/node/load/mocks" + memMocks "github.com/retr0h/osapi/internal/provider/node/mem/mocks" +) + +type FactsTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *mocks.MockJobClient + mockFactsKV *mocks.MockKeyValue + mockHostProvider *hostMocks.MockProvider + mockNetinfo *netinfoMocks.MockProvider + agent *Agent +} + +func (s *FactsTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = mocks.NewMockJobClient(s.mockCtrl) + s.mockFactsKV = mocks.NewMockKeyValue(s.mockCtrl) + s.mockHostProvider = hostMocks.NewDefaultMockProvider(s.mockCtrl) + s.mockNetinfo = netinfoMocks.NewDefaultMockProvider(s.mockCtrl) + + appConfig := config.Config{ + Agent: config.AgentConfig{ + Labels: map[string]string{"group": "web"}, + }, + } + + s.agent = New( + afero.NewMemMapFs(), + appConfig, + slog.Default(), + s.mockJobClient, + "test-stream", + s.mockHostProvider, + diskMocks.NewDefaultMockProvider(s.mockCtrl), + memMocks.NewDefaultMockProvider(s.mockCtrl), + loadMocks.NewDefaultMockProvider(s.mockCtrl), + dnsMocks.NewDefaultMockProvider(s.mockCtrl), + pingMocks.NewDefaultMockProvider(s.mockCtrl), + s.mockNetinfo, + commandMocks.NewDefaultMockProvider(s.mockCtrl), + nil, + s.mockFactsKV, + ) +} + +func (s *FactsTestSuite) TearDownTest() { + s.mockCtrl.Finish() + marshalJSON = json.Marshal + factsInterval = 60 * time.Second +} + +func (s *FactsTestSuite) TestWriteFacts() { + tests := []struct { + name string + setupMock func() + teardownMock func() + validateFunc func() + }{ + { + name: "when Put succeeds writes facts", + setupMock: func() { + s.mockFactsKV.EXPECT(). + Put(gomock.Any(), "facts.test_agent", gomock.Any()). + DoAndReturn(func( + _ context.Context, + _ string, + data []byte, + ) (uint64, error) { + var reg job.FactsRegistration + err := json.Unmarshal(data, ®) + s.NoError(err) + s.Equal("amd64", reg.Architecture) + s.Equal(4, reg.CPUCount) + s.Equal("default-hostname.local", reg.FQDN) + s.Equal("5.15.0-91-generic", reg.KernelVersion) + s.Equal("systemd", reg.ServiceMgr) + s.Equal("apt", reg.PackageMgr) + s.Len(reg.Interfaces, 1) + s.Equal("eth0", reg.Interfaces[0].Name) + return uint64(1), nil + }) + }, + }, + { + name: "when Put fails logs warning", + setupMock: func() { + s.mockFactsKV.EXPECT(). + Put(gomock.Any(), "facts.test_agent", gomock.Any()). + Return(uint64(0), errors.New("put failed")) + }, + }, + { + name: "when marshal fails logs warning", + setupMock: func() { + marshalJSON = func(_ interface{}) ([]byte, error) { + return nil, fmt.Errorf("marshal failure") + } + }, + teardownMock: func() { + marshalJSON = json.Marshal + }, + }, + { + name: "when provider errors partial data still written", + setupMock: func() { + // Override the default mock expectations with error-returning ones. + // Since the default mock provider uses AnyTimes(), these new + // expectations won't conflict. + s.agent.hostProvider = func() *hostMocks.MockProvider { + m := hostMocks.NewPlainMockProvider(s.mockCtrl) + m.EXPECT().GetArchitecture().Return("", errors.New("arch fail")).AnyTimes() + m.EXPECT().GetKernelVersion().Return("", errors.New("kernel fail")).AnyTimes() + m.EXPECT().GetFQDN().Return("", errors.New("fqdn fail")).AnyTimes() + m.EXPECT().GetCPUCount().Return(0, errors.New("cpu fail")).AnyTimes() + m.EXPECT().GetServiceManager().Return("", errors.New("svc fail")).AnyTimes() + m.EXPECT().GetPackageManager().Return("", errors.New("pkg fail")).AnyTimes() + return m + }() + + s.agent.netinfoProvider = func() *netinfoMocks.MockProvider { + m := netinfoMocks.NewPlainMockProvider(s.mockCtrl) + m.EXPECT().GetInterfaces().Return(nil, errors.New("net fail")).AnyTimes() + return m + }() + + s.mockFactsKV.EXPECT(). + Put(gomock.Any(), "facts.test_agent", gomock.Any()). + DoAndReturn(func( + _ context.Context, + _ string, + data []byte, + ) (uint64, error) { + var reg job.FactsRegistration + err := json.Unmarshal(data, ®) + s.NoError(err) + // All fields should be zero values since providers failed. + s.Empty(reg.Architecture) + s.Empty(reg.KernelVersion) + s.Empty(reg.FQDN) + s.Zero(reg.CPUCount) + s.Empty(reg.ServiceMgr) + s.Empty(reg.PackageMgr) + s.Nil(reg.Interfaces) + return uint64(1), nil + }) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + if tt.teardownMock != nil { + defer tt.teardownMock() + } + s.agent.writeFacts(context.Background(), "test-agent") + }) + } +} + +func (s *FactsTestSuite) TestStartFactsRefresh() { + tests := []struct { + name string + setupMock func() + }{ + { + name: "ticker fires and refreshes facts", + setupMock: func() { + // Initial write + at least 1 ticker refresh + s.mockFactsKV.EXPECT(). + Put(gomock.Any(), "facts.test_agent", gomock.Any()). + Return(uint64(1), nil). + MinTimes(2) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + factsInterval = 10 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + s.agent.startFacts(ctx, "test-agent") + + // Wait for at least one ticker refresh + time.Sleep(50 * time.Millisecond) + cancel() + + // Wait for goroutine to finish + s.agent.wg.Wait() + }) + } +} + +func (s *FactsTestSuite) TestFactsKey() { + tests := []struct { + name string + hostname string + expected string + }{ + { + name: "simple hostname", + hostname: "web-01", + expected: "facts.web_01", + }, + { + name: "hostname with dots", + hostname: "Johns-MacBook-Pro.local", + expected: "facts.Johns_MacBook_Pro_local", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + result := factsKey(tt.hostname) + s.Equal(tt.expected, result) + }) + } +} + +func TestFactsTestSuite(t *testing.T) { + suite.Run(t, new(FactsTestSuite)) +} diff --git a/internal/agent/handler_test.go b/internal/agent/handler_test.go index 9166c509..cde0ed24 100644 --- a/internal/agent/handler_test.go +++ b/internal/agent/handler_test.go @@ -37,6 +37,7 @@ import ( commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" "github.com/retr0h/osapi/internal/provider/network/dns" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" "github.com/retr0h/osapi/internal/provider/network/ping" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" @@ -96,6 +97,7 @@ func (s *HandlerTestSuite) SetupTest() { PacketLoss: 0, }, nil).AnyTimes() + netinfoMock := netinfoMocks.NewDefaultMockProvider(s.mockCtrl) commandMock := commandMocks.NewDefaultMockProvider(s.mockCtrl) s.agent = New( @@ -110,8 +112,10 @@ func (s *HandlerTestSuite) SetupTest() { loadMock, dnsMock, pingMock, + netinfoMock, commandMock, nil, + nil, ) } diff --git a/internal/agent/heartbeat_public_test.go b/internal/agent/heartbeat_public_test.go index 0eb94396..d607d56f 100644 --- a/internal/agent/heartbeat_public_test.go +++ b/internal/agent/heartbeat_public_test.go @@ -35,6 +35,7 @@ import ( "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" @@ -134,8 +135,10 @@ func (s *HeartbeatPublicTestSuite) TestStartWithHeartbeat() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), s.mockKV, + nil, ) }, stopFunc: func(a *agent.Agent) { @@ -171,8 +174,10 @@ func (s *HeartbeatPublicTestSuite) TestStartWithHeartbeat() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), nil, + nil, ) }, stopFunc: func(a *agent.Agent) { diff --git a/internal/agent/heartbeat_test.go b/internal/agent/heartbeat_test.go index d8d7c190..eacc6ef5 100644 --- a/internal/agent/heartbeat_test.go +++ b/internal/agent/heartbeat_test.go @@ -37,6 +37,7 @@ import ( "github.com/retr0h/osapi/internal/job/mocks" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" @@ -77,8 +78,10 @@ func (s *HeartbeatTestSuite) SetupTest() { loadMocks.NewDefaultMockProvider(s.mockCtrl), dnsMocks.NewDefaultMockProvider(s.mockCtrl), pingMocks.NewDefaultMockProvider(s.mockCtrl), + netinfoMocks.NewDefaultMockProvider(s.mockCtrl), commandMocks.NewDefaultMockProvider(s.mockCtrl), s.mockKV, + nil, ) } diff --git a/internal/agent/processor_command_test.go b/internal/agent/processor_command_test.go index 975ca70b..e0da3093 100644 --- a/internal/agent/processor_command_test.go +++ b/internal/agent/processor_command_test.go @@ -36,6 +36,7 @@ import ( "github.com/retr0h/osapi/internal/provider/command" commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" hostMocks "github.com/retr0h/osapi/internal/provider/node/host/mocks" @@ -74,8 +75,10 @@ func (s *ProcessorCommandTestSuite) newAgentWithCommandMock( loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), cmdMock, nil, + nil, ) } diff --git a/internal/agent/processor_test.go b/internal/agent/processor_test.go index 36015cda..a26940dd 100644 --- a/internal/agent/processor_test.go +++ b/internal/agent/processor_test.go @@ -37,6 +37,7 @@ import ( commandMocks "github.com/retr0h/osapi/internal/provider/command/mocks" "github.com/retr0h/osapi/internal/provider/network/dns" dnsMocks "github.com/retr0h/osapi/internal/provider/network/dns/mocks" + netinfoMocks "github.com/retr0h/osapi/internal/provider/network/netinfo/mocks" "github.com/retr0h/osapi/internal/provider/network/ping" pingMocks "github.com/retr0h/osapi/internal/provider/network/ping/mocks" diskMocks "github.com/retr0h/osapi/internal/provider/node/disk/mocks" @@ -101,6 +102,7 @@ func (s *ProcessorTestSuite) SetupTest() { PacketLoss: 0, }, nil).AnyTimes() + netinfoMock := netinfoMocks.NewDefaultMockProvider(s.mockCtrl) commandMock := commandMocks.NewDefaultMockProvider(s.mockCtrl) s.agent = New( @@ -115,8 +117,10 @@ func (s *ProcessorTestSuite) SetupTest() { loadMock, dnsMock, pingMock, + netinfoMock, commandMock, nil, + nil, ) } @@ -658,8 +662,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -684,8 +690,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -708,8 +716,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -732,8 +742,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -756,8 +768,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -780,8 +794,10 @@ func (s *ProcessorTestSuite) TestSystemOperationErrors() { loadMock, dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -838,8 +854,10 @@ func (s *ProcessorTestSuite) TestNetworkOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMock, pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -866,8 +884,10 @@ func (s *ProcessorTestSuite) TestNetworkOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMock, pingMocks.NewPlainMockProvider(s.mockCtrl), + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, @@ -892,8 +912,10 @@ func (s *ProcessorTestSuite) TestNetworkOperationErrors() { loadMocks.NewPlainMockProvider(s.mockCtrl), dnsMocks.NewPlainMockProvider(s.mockCtrl), pingMock, + netinfoMocks.NewPlainMockProvider(s.mockCtrl), commandMocks.NewPlainMockProvider(s.mockCtrl), nil, + nil, ) }, }, diff --git a/internal/agent/server.go b/internal/agent/server.go index 063e7d4f..c1c0ec18 100644 --- a/internal/agent/server.go +++ b/internal/agent/server.go @@ -49,6 +49,9 @@ func (a *Agent) Start() { // Register in agent registry and start heartbeat keepalive. a.startHeartbeat(a.ctx, hostname) + // Collect and publish system facts. + a.startFacts(a.ctx, hostname) + // Start consuming messages for different job types. // Each consume function spawns goroutines tracked by a.wg. _ = a.consumeQueryJobs(a.ctx, hostname) diff --git a/internal/agent/types.go b/internal/agent/types.go index eea79bee..5e97581c 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -33,6 +33,7 @@ import ( "github.com/retr0h/osapi/internal/job/client" "github.com/retr0h/osapi/internal/provider/command" "github.com/retr0h/osapi/internal/provider/network/dns" + "github.com/retr0h/osapi/internal/provider/network/netinfo" "github.com/retr0h/osapi/internal/provider/network/ping" "github.com/retr0h/osapi/internal/provider/node/disk" "github.com/retr0h/osapi/internal/provider/node/host" @@ -58,12 +59,18 @@ type Agent struct { dnsProvider dns.Provider pingProvider ping.Provider + // Network info provider + netinfoProvider netinfo.Provider + // Command provider commandProvider command.Provider // Registry KV for heartbeat registration registryKV jetstream.KeyValue + // Facts KV for system facts collection + factsKV jetstream.KeyValue + // startedAt records when the agent process started. startedAt time.Time From daf096592c753f4f97018d62bb63ad663c94449a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:27:35 -0800 Subject: [PATCH 12/24] feat(provider): add IP family to NetworkInterface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Family field ("inet", "inet6", "dual") computed from which addresses are present on the interface. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/job/types.go | 9 +++++---- internal/job/types_public_test.go | 14 +++++++++----- internal/provider/network/netinfo/mocks/mocks.go | 2 +- internal/provider/network/netinfo/netinfo.go | 9 +++++++++ 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/internal/job/types.go b/internal/job/types.go index ea620fdf..77c87403 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -251,10 +251,11 @@ type NodeShutdownData struct { // NetworkInterface represents a network interface with its address. type NetworkInterface struct { - Name string `json:"name"` - IPv4 string `json:"ipv4,omitempty"` - IPv6 string `json:"ipv6,omitempty"` - MAC string `json:"mac,omitempty"` + Name string `json:"name"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + MAC string `json:"mac,omitempty"` + Family string `json:"family,omitempty"` } // FactsRegistration represents an agent's facts entry in the facts KV bucket. diff --git a/internal/job/types_public_test.go b/internal/job/types_public_test.go index f582c4d2..188e814d 100644 --- a/internal/job/types_public_test.go +++ b/internal/job/types_public_test.go @@ -50,16 +50,18 @@ func (suite *TypesPublicTestSuite) TestNetworkInterfaceJSONRoundTrip() { { name: "when all fields are set", iface: job.NetworkInterface{ - Name: "eth0", - IPv4: "192.168.1.100", - IPv6: "fe80::1", - MAC: "00:1a:2b:3c:4d:5e", + Name: "eth0", + IPv4: "192.168.1.100", + IPv6: "fe80::1", + MAC: "00:1a:2b:3c:4d:5e", + Family: "dual", }, validateFunc: func(result job.NetworkInterface) { suite.Equal("eth0", result.Name) suite.Equal("192.168.1.100", result.IPv4) suite.Equal("fe80::1", result.IPv6) suite.Equal("00:1a:2b:3c:4d:5e", result.MAC) + suite.Equal("dual", result.Family) }, }, { @@ -72,6 +74,7 @@ func (suite *TypesPublicTestSuite) TestNetworkInterfaceJSONRoundTrip() { suite.Empty(result.IPv4) suite.Empty(result.IPv6) suite.Empty(result.MAC) + suite.Empty(result.Family) }, }, { @@ -231,7 +234,7 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { ServiceMgr: "systemd", PackageMgr: "apt", Interfaces: []job.NetworkInterface{ - {Name: "eth0", IPv4: "10.0.0.1", IPv6: "fe80::1", MAC: "aa:bb:cc:dd:ee:ff"}, + {Name: "eth0", IPv4: "10.0.0.1", IPv6: "fe80::1", MAC: "aa:bb:cc:dd:ee:ff", Family: "dual"}, }, Facts: map[string]any{ "custom": "value", @@ -251,6 +254,7 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { suite.Equal("10.0.0.1", result.Interfaces[0].IPv4) suite.Equal("fe80::1", result.Interfaces[0].IPv6) suite.Equal("aa:bb:cc:dd:ee:ff", result.Interfaces[0].MAC) + suite.Equal("dual", result.Interfaces[0].Family) suite.Equal("value", result.Facts["custom"]) }, }, diff --git a/internal/provider/network/netinfo/mocks/mocks.go b/internal/provider/network/netinfo/mocks/mocks.go index c69941b3..fdfc423b 100644 --- a/internal/provider/network/netinfo/mocks/mocks.go +++ b/internal/provider/network/netinfo/mocks/mocks.go @@ -36,7 +36,7 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider { mock := NewMockProvider(ctrl) mock.EXPECT().GetInterfaces().Return([]job.NetworkInterface{ - {Name: "eth0", IPv4: "192.168.1.10", IPv6: "fe80::1", MAC: "00:11:22:33:44:55"}, + {Name: "eth0", IPv4: "192.168.1.10", IPv6: "fe80::1", MAC: "00:11:22:33:44:55", Family: "dual"}, }, nil).AnyTimes() return mock diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go index 956e1c86..b1562923 100644 --- a/internal/provider/network/netinfo/netinfo.go +++ b/internal/provider/network/netinfo/netinfo.go @@ -74,6 +74,15 @@ func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { } } + switch { + case ni.IPv4 != "" && ni.IPv6 != "": + ni.Family = "dual" + case ni.IPv6 != "": + ni.Family = "inet6" + case ni.IPv4 != "": + ni.Family = "inet" + } + result = append(result, ni) } From 6298313f1b2f3224ec294a943d73a285464c71a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:30:58 -0800 Subject: [PATCH 13/24] feat(job): merge facts KV data into ListAgents and GetAgent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mergeFacts helper that reads from the facts KV bucket and enriches AgentInfo with architecture, kernel version, CPU count, FQDN, service manager, package manager, network interfaces, and custom facts. Called in both ListAgents and GetAgent after building info from registration. Gracefully degrades when factsKV is nil or Get returns an error. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/job/client/query.go | 36 +++- internal/job/client/query_public_test.go | 245 +++++++++++++++++++++-- 2 files changed, 266 insertions(+), 15 deletions(-) diff --git a/internal/job/client/query.go b/internal/job/client/query.go index fd80e40a..f715c712 100644 --- a/internal/job/client/query.go +++ b/internal/job/client/query.go @@ -411,7 +411,9 @@ func (c *Client) ListAgents( continue } - agents = append(agents, agentInfoFromRegistration(®)) + info := agentInfoFromRegistration(®) + c.mergeFacts(ctx, &info) + agents = append(agents, info) } return agents, nil @@ -438,9 +440,41 @@ func (c *Client) GetAgent( } info := agentInfoFromRegistration(®) + c.mergeFacts(ctx, &info) return &info, nil } +// mergeFacts reads facts from the facts KV bucket and merges them into the +// AgentInfo. If factsKV is nil or the read fails, this is a no-op. +func (c *Client) mergeFacts( + ctx context.Context, + info *job.AgentInfo, +) { + if c.factsKV == nil { + return + } + + key := "facts." + job.SanitizeHostname(info.Hostname) + entry, err := c.factsKV.Get(ctx, key) + if err != nil { + return + } + + var facts job.FactsRegistration + if err := json.Unmarshal(entry.Value(), &facts); err != nil { + return + } + + info.Architecture = facts.Architecture + info.KernelVersion = facts.KernelVersion + info.CPUCount = facts.CPUCount + info.FQDN = facts.FQDN + info.ServiceMgr = facts.ServiceMgr + info.PackageMgr = facts.PackageMgr + info.Interfaces = facts.Interfaces + info.Facts = facts.Facts +} + // agentInfoFromRegistration maps an AgentRegistration to an AgentInfo. func agentInfoFromRegistration( reg *job.AgentRegistration, diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go index ee3b9c67..99d89e5a 100644 --- a/internal/job/client/query_public_test.go +++ b/internal/job/client/query_public_test.go @@ -1131,13 +1131,15 @@ func (s *QueryPublicTestSuite) TestQueryNetworkPingAll() { func (s *QueryPublicTestSuite) TestListAgents() { tests := []struct { - name string - setupMockKV func(*jobmocks.MockKeyValue) - useRegistryKV bool - expectError bool - errorContains string - expectedCount int - validateFunc func([]job.AgentInfo) + name string + setupMockKV func(*jobmocks.MockKeyValue) + setupMockFactsKV func(*jobmocks.MockKeyValue) + useRegistryKV bool + useFactsKV bool + expectError bool + errorContains string + expectedCount int + validateFunc func([]job.AgentInfo) }{ { name: "when registryKV is nil returns error", @@ -1252,6 +1254,111 @@ func (s *QueryPublicTestSuite) TestListAgents() { }, expectedCount: 1, }, + { + name: "when factsKV has data merges facts into agent info", + useRegistryKV: true, + useFactsKV: true, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Keys(gomock.Any()). + Return([]string{"agents.server1"}, nil) + + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","labels":{"group":"web"},"registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + setupMockFactsKV: func(kv *jobmocks.MockKeyValue) { + factsEntry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + factsEntry.EXPECT().Value().Return( + []byte( + `{"architecture":"x86_64","kernel_version":"6.1.0","cpu_count":8,"fqdn":"server1.example.com","service_mgr":"systemd","package_mgr":"apt","interfaces":[{"name":"eth0","ipv4":"10.0.0.1"}],"facts":{"custom_key":"custom_value"}}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "facts.server1"). + Return(factsEntry, nil) + }, + expectedCount: 1, + validateFunc: func(agents []job.AgentInfo) { + s.Equal("server1", agents[0].Hostname) + s.Equal("x86_64", agents[0].Architecture) + s.Equal("6.1.0", agents[0].KernelVersion) + s.Equal(8, agents[0].CPUCount) + s.Equal("server1.example.com", agents[0].FQDN) + s.Equal("systemd", agents[0].ServiceMgr) + s.Equal("apt", agents[0].PackageMgr) + s.Len(agents[0].Interfaces, 1) + s.Equal("eth0", agents[0].Interfaces[0].Name) + s.Equal("custom_value", agents[0].Facts["custom_key"]) + }, + }, + { + name: "when factsKV is nil degrades gracefully", + useRegistryKV: true, + useFactsKV: false, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Keys(gomock.Any()). + Return([]string{"agents.server1"}, nil) + + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + expectedCount: 1, + validateFunc: func(agents []job.AgentInfo) { + s.Equal("server1", agents[0].Hostname) + s.Empty(agents[0].Architecture) + s.Empty(agents[0].KernelVersion) + s.Zero(agents[0].CPUCount) + s.Empty(agents[0].FQDN) + s.Nil(agents[0].Interfaces) + s.Nil(agents[0].Facts) + }, + }, + { + name: "when factsKV Get returns error degrades gracefully", + useRegistryKV: true, + useFactsKV: true, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Keys(gomock.Any()). + Return([]string{"agents.server1"}, nil) + + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + setupMockFactsKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Get(gomock.Any(), "facts.server1"). + Return(nil, errors.New("key not found")) + }, + expectedCount: 1, + validateFunc: func(agents []job.AgentInfo) { + s.Equal("server1", agents[0].Hostname) + s.Empty(agents[0].Architecture) + s.Empty(agents[0].KernelVersion) + }, + }, } for _, tt := range tests { @@ -1268,6 +1375,13 @@ func (s *QueryPublicTestSuite) TestListAgents() { if tt.useRegistryKV { opts.RegistryKV = registryKV } + if tt.useFactsKV { + factsKV := jobmocks.NewMockKeyValue(s.mockCtrl) + if tt.setupMockFactsKV != nil { + tt.setupMockFactsKV(factsKV) + } + opts.FactsKV = factsKV + } jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) s.Require().NoError(err) @@ -1293,13 +1407,15 @@ func (s *QueryPublicTestSuite) TestListAgents() { func (s *QueryPublicTestSuite) TestGetAgent() { tests := []struct { - name string - hostname string - setupMockKV func(*jobmocks.MockKeyValue) - useRegistryKV bool - expectError bool - errorContains string - validateFunc func(*job.AgentInfo) + name string + hostname string + setupMockKV func(*jobmocks.MockKeyValue) + setupMockFactsKV func(*jobmocks.MockKeyValue) + useRegistryKV bool + useFactsKV bool + expectError bool + errorContains string + validateFunc func(*job.AgentInfo) }{ { name: "when registryKV is nil returns error", @@ -1358,6 +1474,100 @@ func (s *QueryPublicTestSuite) TestGetAgent() { expectError: true, errorContains: "failed to unmarshal", }, + { + name: "when factsKV has data merges facts into agent info", + hostname: "server1", + useRegistryKV: true, + useFactsKV: true, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","labels":{"group":"web"},"registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + setupMockFactsKV: func(kv *jobmocks.MockKeyValue) { + factsEntry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + factsEntry.EXPECT().Value().Return( + []byte( + `{"architecture":"aarch64","kernel_version":"5.15.0","cpu_count":4,"fqdn":"server1.local","service_mgr":"systemd","package_mgr":"yum","interfaces":[{"name":"ens3","ipv4":"192.168.1.10","mac":"aa:bb:cc:dd:ee:ff"}],"facts":{"env":"prod"}}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "facts.server1"). + Return(factsEntry, nil) + }, + validateFunc: func(info *job.AgentInfo) { + s.Equal("server1", info.Hostname) + s.Equal("aarch64", info.Architecture) + s.Equal("5.15.0", info.KernelVersion) + s.Equal(4, info.CPUCount) + s.Equal("server1.local", info.FQDN) + s.Equal("systemd", info.ServiceMgr) + s.Equal("yum", info.PackageMgr) + s.Len(info.Interfaces, 1) + s.Equal("ens3", info.Interfaces[0].Name) + s.Equal("aa:bb:cc:dd:ee:ff", info.Interfaces[0].MAC) + s.Equal("prod", info.Facts["env"]) + }, + }, + { + name: "when factsKV is nil degrades gracefully", + hostname: "server1", + useRegistryKV: true, + useFactsKV: false, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + validateFunc: func(info *job.AgentInfo) { + s.Equal("server1", info.Hostname) + s.Empty(info.Architecture) + s.Empty(info.KernelVersion) + s.Zero(info.CPUCount) + s.Empty(info.FQDN) + s.Nil(info.Interfaces) + s.Nil(info.Facts) + }, + }, + { + name: "when factsKV Get returns error degrades gracefully", + hostname: "server1", + useRegistryKV: true, + useFactsKV: true, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + setupMockFactsKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Get(gomock.Any(), "facts.server1"). + Return(nil, errors.New("key not found")) + }, + validateFunc: func(info *job.AgentInfo) { + s.Equal("server1", info.Hostname) + s.Empty(info.Architecture) + s.Empty(info.KernelVersion) + }, + }, } for _, tt := range tests { @@ -1374,6 +1584,13 @@ func (s *QueryPublicTestSuite) TestGetAgent() { if tt.useRegistryKV { opts.RegistryKV = registryKV } + if tt.useFactsKV { + factsKV := jobmocks.NewMockKeyValue(s.mockCtrl) + if tt.setupMockFactsKV != nil { + tt.setupMockFactsKV(factsKV) + } + opts.FactsKV = factsKV + } jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) s.Require().NoError(err) From 05732a1cd703b25e1d1288f988d22cd4450f62af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:33:56 -0800 Subject: [PATCH 14/24] feat(api): expose agent facts in AgentInfo responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add architecture, kernel_version, cpu_count, fqdn, service_mgr, package_mgr, interfaces, and facts fields to the AgentInfo OpenAPI schema. Add NetworkInterfaceResponse schema with name, ipv4, ipv6, mac, and family fields. Update buildAgentInfo to map new domain fields to the generated API types. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/api/agent/agent_list.go | 61 +++++++++++++++++++++++++++++ internal/api/agent/gen/agent.gen.go | 44 ++++++++++++++++++++- internal/api/agent/gen/api.yaml | 58 +++++++++++++++++++++++++++ internal/api/gen/api.yaml | 57 +++++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 1 deletion(-) diff --git a/internal/api/agent/agent_list.go b/internal/api/agent/agent_list.go index d63efc68..b2628d53 100644 --- a/internal/api/agent/agent_list.go +++ b/internal/api/agent/agent_list.go @@ -106,6 +106,67 @@ func buildAgentInfo( } } + if a.Architecture != "" { + arch := a.Architecture + info.Architecture = &arch + } + + if a.KernelVersion != "" { + kv := a.KernelVersion + info.KernelVersion = &kv + } + + if a.CPUCount > 0 { + cpuCount := a.CPUCount + info.CpuCount = &cpuCount + } + + if a.FQDN != "" { + fqdn := a.FQDN + info.Fqdn = &fqdn + } + + if a.ServiceMgr != "" { + svcMgr := a.ServiceMgr + info.ServiceMgr = &svcMgr + } + + if a.PackageMgr != "" { + pkgMgr := a.PackageMgr + info.PackageMgr = &pkgMgr + } + + if len(a.Interfaces) > 0 { + ifaces := make([]gen.NetworkInterfaceResponse, len(a.Interfaces)) + for i, ni := range a.Interfaces { + ifaces[i] = gen.NetworkInterfaceResponse{ + Name: ni.Name, + } + if ni.IPv4 != "" { + ipv4 := ni.IPv4 + ifaces[i].Ipv4 = &ipv4 + } + if ni.IPv6 != "" { + ipv6 := ni.IPv6 + ifaces[i].Ipv6 = &ipv6 + } + if ni.MAC != "" { + mac := ni.MAC + ifaces[i].Mac = &mac + } + if ni.Family != "" { + family := gen.NetworkInterfaceResponseFamily(ni.Family) + ifaces[i].Family = &family + } + } + info.Interfaces = &ifaces + } + + if len(a.Facts) > 0 { + facts := a.Facts + info.Facts = &facts + } + return info } diff --git a/internal/api/agent/gen/agent.gen.go b/internal/api/agent/gen/agent.gen.go index db8c574e..60acbb7c 100644 --- a/internal/api/agent/gen/agent.gen.go +++ b/internal/api/agent/gen/agent.gen.go @@ -26,10 +26,33 @@ const ( Ready AgentInfoStatus = "Ready" ) +// Defines values for NetworkInterfaceResponseFamily. +const ( + Dual NetworkInterfaceResponseFamily = "dual" + Inet NetworkInterfaceResponseFamily = "inet" + Inet6 NetworkInterfaceResponseFamily = "inet6" +) + // AgentInfo defines model for AgentInfo. type AgentInfo struct { + // Architecture CPU architecture. + Architecture *string `json:"architecture,omitempty"` + + // CpuCount Number of logical CPUs. + CpuCount *int `json:"cpu_count,omitempty"` + + // Facts Extended facts from additional providers. + Facts *map[string]interface{} `json:"facts,omitempty"` + + // Fqdn Fully qualified domain name. + Fqdn *string `json:"fqdn,omitempty"` + // Hostname The hostname of the agent. - Hostname string `json:"hostname"` + Hostname string `json:"hostname"` + Interfaces *[]NetworkInterfaceResponse `json:"interfaces,omitempty"` + + // KernelVersion OS kernel version. + KernelVersion *string `json:"kernel_version,omitempty"` // Labels Key-value labels configured on the agent. Labels *map[string]string `json:"labels,omitempty"` @@ -43,9 +66,15 @@ type AgentInfo struct { // OsInfo Operating system information. OsInfo *OSInfoResponse `json:"os_info,omitempty"` + // PackageMgr Package manager. + PackageMgr *string `json:"package_mgr,omitempty"` + // RegisteredAt When the agent last refreshed its heartbeat. RegisteredAt *time.Time `json:"registered_at,omitempty"` + // ServiceMgr Init system. + ServiceMgr *string `json:"service_mgr,omitempty"` + // StartedAt When the agent process started. StartedAt *time.Time `json:"started_at,omitempty"` @@ -94,6 +123,19 @@ type MemoryResponse struct { Used int `json:"used"` } +// NetworkInterfaceResponse defines model for NetworkInterfaceResponse. +type NetworkInterfaceResponse struct { + // Family IP address family. + Family *NetworkInterfaceResponseFamily `json:"family,omitempty"` + Ipv4 *string `json:"ipv4,omitempty"` + Ipv6 *string `json:"ipv6,omitempty"` + Mac *string `json:"mac,omitempty"` + Name string `json:"name"` +} + +// NetworkInterfaceResponseFamily IP address family. +type NetworkInterfaceResponseFamily string + // OSInfoResponse Operating system information. type OSInfoResponse struct { // Distribution The name of the Linux distribution. diff --git a/internal/api/agent/gen/api.yaml b/internal/api/agent/gen/api.yaml index 991f46b3..ad1f5d42 100644 --- a/internal/api/agent/gen/api.yaml +++ b/internal/api/agent/gen/api.yaml @@ -168,6 +168,38 @@ components: $ref: '#/components/schemas/LoadAverageResponse' memory: $ref: '#/components/schemas/MemoryResponse' + architecture: + type: string + description: CPU architecture. + example: "amd64" + kernel_version: + type: string + description: OS kernel version. + example: "5.15.0-91-generic" + cpu_count: + type: integer + description: Number of logical CPUs. + example: 4 + fqdn: + type: string + description: Fully qualified domain name. + example: "web-01.example.com" + service_mgr: + type: string + description: Init system. + example: "systemd" + package_mgr: + type: string + description: Package manager. + example: "apt" + interfaces: + type: array + items: + $ref: '#/components/schemas/NetworkInterfaceResponse' + facts: + type: object + additionalProperties: true + description: Extended facts from additional providers. required: - hostname - status @@ -229,3 +261,29 @@ components: required: - distribution - version + + NetworkInterfaceResponse: + type: object + properties: + name: + type: string + example: "eth0" + ipv4: + type: string + example: "192.168.1.10" + ipv6: + type: string + example: "fe80::1" + mac: + type: string + example: "00:11:22:33:44:55" + family: + type: string + description: IP address family. + example: "dual" + enum: + - inet + - inet6 + - dual + required: + - name diff --git a/internal/api/gen/api.yaml b/internal/api/gen/api.yaml index f9ff981a..0dbe61d0 100644 --- a/internal/api/gen/api.yaml +++ b/internal/api/gen/api.yaml @@ -1348,6 +1348,38 @@ components: $ref: '#/components/schemas/LoadAverageResponse' memory: $ref: '#/components/schemas/MemoryResponse' + architecture: + type: string + description: CPU architecture. + example: amd64 + kernel_version: + type: string + description: OS kernel version. + example: 5.15.0-91-generic + cpu_count: + type: integer + description: Number of logical CPUs. + example: 4 + fqdn: + type: string + description: Fully qualified domain name. + example: web-01.example.com + service_mgr: + type: string + description: Init system. + example: systemd + package_mgr: + type: string + description: Package manager. + example: apt + interfaces: + type: array + items: + $ref: '#/components/schemas/NetworkInterfaceResponse' + facts: + type: object + additionalProperties: true + description: Extended facts from additional providers. required: - hostname - status @@ -1406,6 +1438,31 @@ components: required: - distribution - version + NetworkInterfaceResponse: + type: object + properties: + name: + type: string + example: eth0 + ipv4: + type: string + example: 192.168.1.10 + ipv6: + type: string + example: fe80::1 + mac: + type: string + example: '00:11:22:33:44:55' + family: + type: string + description: IP address family. + example: dual + enum: + - inet + - inet6 + - dual + required: + - name AuditEntry: type: object properties: From 7fd994f427b520a3e57bf857d1a09d7b15e0b44e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:48:13 -0800 Subject: [PATCH 15/24] chore: add default facts config to osapi.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add nats.facts (bucket, TTL, storage) and agent.facts (interval) configuration sections. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- configs/osapi.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/configs/osapi.yaml b/configs/osapi.yaml index 7ca3db88..e037a11a 100644 --- a/configs/osapi.yaml +++ b/configs/osapi.yaml @@ -92,6 +92,12 @@ nats: storage: file replicas: 1 + facts: + bucket: agent-facts + ttl: 5m + storage: file + replicas: 1 + telemetry: tracing: enabled: true @@ -125,3 +131,5 @@ agent: max_jobs: 10 labels: group: web.dev.us-east # hierarchical: --target group:web, group:web.dev, etc. + facts: + interval: 60s From c5a671f93e8ec394431a902b288092dea2c777fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:50:10 -0800 Subject: [PATCH 16/24] docs: add facts configuration reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/usage/configuration.md | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 144e98c9..fa715421 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -50,6 +50,10 @@ uppercased: | `nats.registry.ttl` | `OSAPI_NATS_REGISTRY_TTL` | | `nats.registry.storage` | `OSAPI_NATS_REGISTRY_STORAGE` | | `nats.registry.replicas` | `OSAPI_NATS_REGISTRY_REPLICAS` | +| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | +| `nats.facts.ttl` | `OSAPI_NATS_FACTS_TTL` | +| `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | +| `nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | | `telemetry.tracing.enabled` | `OSAPI_TELEMETRY_TRACING_ENABLED` | | `telemetry.tracing.exporter` | `OSAPI_TELEMETRY_TRACING_EXPORTER` | | `telemetry.tracing.otlp_endpoint` | `OSAPI_TELEMETRY_TRACING_OTLP_ENDPOINT` | @@ -59,6 +63,7 @@ uppercased: | `agent.nats.namespace` | `OSAPI_AGENT_NATS_NAMESPACE` | | `agent.nats.auth.type` | `OSAPI_AGENT_NATS_AUTH_TYPE` | | `agent.hostname` | `OSAPI_AGENT_HOSTNAME` | +| `agent.facts.interval` | `OSAPI_AGENT_FACTS_INTERVAL` | Environment variables take precedence over file values. @@ -308,6 +313,17 @@ nats: # Number of KV replicas. replicas: 1 + # ── Facts KV bucket ────────────────────────────────────── + facts: + # KV bucket for agent facts entries. + bucket: 'agent-facts' + # TTL for facts entries (Go duration). + ttl: '5m' + # Storage backend: "file" or "memory". + storage: 'file' + # Number of KV replicas. + replicas: 1 + # ── Dead Letter Queue ───────────────────────────────────── dlq: # Maximum age of messages in the DLQ. @@ -359,6 +375,10 @@ agent: - '5m' - '15m' - '30m' + # Facts collection settings. + facts: + # How often the agent collects and publishes facts. + interval: '60s' # Queue group for load-balanced (_any) subscriptions. queue_group: 'job-agents' # Agent hostname for direct routing. Defaults to the @@ -452,6 +472,15 @@ agent: | `storage` | string | `"file"` or `"memory"` | | `replicas` | int | Number of KV replicas | +### `nats.facts` + +| Key | Type | Description | +| ---------- | ------ | --------------------------------- | +| `bucket` | string | KV bucket for agent facts entries | +| `ttl` | string | Entry time-to-live (Go duration) | +| `storage` | string | `"file"` or `"memory"` | +| `replicas` | int | Number of KV replicas | + ### `nats.dlq` | Key | Type | Description | @@ -489,4 +518,5 @@ agent: | `queue_group` | string | Queue group for load-balanced routing | | `hostname` | string | Agent hostname (defaults to OS hostname) | | `max_jobs` | int | Max concurrent jobs | +| `facts.interval` | string | How often the agent collects facts | | `labels` | map[string]string | Key-value pairs for label-based routing | From f11fdb14fa3efb8538a7251d4f75e87928907787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:50:15 -0800 Subject: [PATCH 17/24] docs: update CLI docs and README with agent facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 2 + .../sidebar/usage/cli/client/agent/get.md | 38 +++++++++++++------ .../sidebar/usage/cli/client/agent/list.md | 8 ++++ .../sidebar/usage/cli/client/health/status.md | 1 + 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d2c00ac7..28064460 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/docs/docs/sidebar/usage/cli/client/agent/get.md b/docs/docs/sidebar/usage/cli/client/agent/get.md index 3d17c239..0200bfbb 100644 --- a/docs/docs/sidebar/usage/cli/client/agent/get.md +++ b/docs/docs/sidebar/usage/cli/client/agent/get.md @@ -13,22 +13,38 @@ $ osapi client agent get --hostname web-01 Last Seen: 3s ago Load: 1.74, 1.79, 1.94 (1m, 5m, 15m) Memory: 32.0 GB total, 19.2 GB used, 12.8 GB free + Architecture: amd64 + Kernel: 6.8.0-51-generic + FQDN: web-01.example.com + CPUs: 8 + Service Mgr: systemd + Package Mgr: apt + Interfaces: + eth0: 10.0.1.10 (IPv4), fe80::1 (IPv6), MAC 00:1a:2b:3c:4d:5e + lo: 127.0.0.1 (IPv4), ::1 (IPv6) ``` This command reads directly from the agent heartbeat registry -- no job is created. The data comes from the agent's most recent heartbeat write. -| Field | Description | -| --------- | ------------------------------------- | -| Hostname | Agent's configured or OS hostname | -| Status | `Ready` if present in registry | -| Labels | Key-value labels from agent config | -| OS | Distribution and version | -| Uptime | System uptime reported by the agent | -| Age | Time since the agent process started | -| Last Seen | Time since the last heartbeat refresh | -| Load | 1-, 5-, and 15-minute load averages | -| Memory | Total, used, and free RAM | +| Field | Description | +| ------------ | ---------------------------------------------------- | +| Hostname | Agent's configured or OS hostname | +| Status | `Ready` if present in registry | +| Labels | Key-value labels from agent config | +| OS | Distribution and version | +| Uptime | System uptime reported by the agent | +| Age | Time since the agent process started | +| Last Seen | Time since the last heartbeat refresh | +| Load | 1-, 5-, and 15-minute load averages | +| Memory | Total, used, and free RAM | +| Architecture | CPU architecture (e.g., amd64) | +| Kernel | OS kernel version | +| FQDN | Fully qualified domain name | +| CPUs | Number of logical CPUs | +| Service Mgr | Init system (e.g., systemd) | +| Package Mgr | Package manager (e.g., apt) | +| Interfaces | Network interfaces with IPv4, IPv6, MAC, and family | :::tip agent get vs. node status diff --git a/docs/docs/sidebar/usage/cli/client/agent/list.md b/docs/docs/sidebar/usage/cli/client/agent/list.md index eadd26da..4c0f6973 100644 --- a/docs/docs/sidebar/usage/cli/client/agent/list.md +++ b/docs/docs/sidebar/usage/cli/client/agent/list.md @@ -25,5 +25,13 @@ Agents that stop heartbeating disappear from the list automatically. | LOAD (1m) | 1-minute load average from heartbeat | | OS | Distribution and version from heartbeat | +:::tip Full facts in JSON output + +`--json` output includes additional system facts collected by the agent: +architecture, kernel version, FQDN, CPU count, network interfaces, service +manager, and package manager. These fields are not shown in the table view. + +::: + Use `agent get --hostname X` for detailed information about a specific agent, or `node status` for deep system metrics gathered via the job system. diff --git a/docs/docs/sidebar/usage/cli/client/health/status.md b/docs/docs/sidebar/usage/cli/client/health/status.md index e6d429cb..7ad02263 100644 --- a/docs/docs/sidebar/usage/cli/client/health/status.md +++ b/docs/docs/sidebar/usage/cli/client/health/status.md @@ -22,6 +22,7 @@ $ osapi client health status Stream: JOBS (42 msgs, 1.0 KB, 24 consumers) Bucket: job-queue (10 keys, 2.0 KB) Bucket: agent-registry (2 keys, 256 B) + Bucket: agent-facts (2 keys, 1.5 KB) Bucket: audit-log (50 keys, 8.0 KB) ``` From 4d4a5866bd60ada7d3cb7711863e297872a3adc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 18:56:38 -0800 Subject: [PATCH 18/24] docs: update feature and architecture pages with facts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../sidebar/architecture/job-architecture.md | 23 +++++++++++++++++++ .../architecture/system-architecture.md | 4 ++-- docs/docs/sidebar/features/node-management.md | 22 +++++++++++------- 3 files changed, 39 insertions(+), 10 deletions(-) diff --git a/docs/docs/sidebar/architecture/job-architecture.md b/docs/docs/sidebar/architecture/job-architecture.md index 3f69b4ba..8dfa49b2 100644 --- a/docs/docs/sidebar/architecture/job-architecture.md +++ b/docs/docs/sidebar/architecture/job-architecture.md @@ -86,6 +86,12 @@ graph LR - TTL: 24 hours - Used for agent-to-client result passing +3. **agent-facts**: System facts storage (see + [Facts Collection](#facts-collection) below) + - Key format: `{hostname}` + - TTL: 5 minutes + - Typed system facts gathered by agents independently from the job system + ### JetStream Configuration ```yaml @@ -453,6 +459,23 @@ default: } ``` +### Facts Collection + +Agents collect **system facts** independently from the job system. Facts are +typed system properties — architecture, kernel version, FQDN, CPU count, network +interfaces, service manager, and package manager — gathered via providers on a +60-second interval. + +Facts are stored in a dedicated `agent-facts` KV bucket with a 5-minute TTL, +separate from the `agent-registry` heartbeat bucket. This keeps the heartbeat +lightweight (status and metrics only) while facts carry richer, less frequently +changing data. + +When the API serves an `AgentInfo` response (via `GET /node/{hostname}` or +`GET /node`), it merges data from both KV buckets — registry for status, +labels, and lightweight metrics, and facts for detailed system properties — +into a single unified response. + ## Operation Examples ### System Operations diff --git a/docs/docs/sidebar/architecture/system-architecture.md b/docs/docs/sidebar/architecture/system-architecture.md index 98329f95..c07477dc 100644 --- a/docs/docs/sidebar/architecture/system-architecture.md +++ b/docs/docs/sidebar/architecture/system-architecture.md @@ -19,8 +19,8 @@ The system is organized into six layers, top to bottom: | **SDK Client** | `osapi-sdk` (external) | OpenAPI-generated client used by CLI | | **REST API** | `internal/api/` | Echo server with JWT middleware | | **Job Client** | `internal/job/client/` | Business logic for job CRUD and status | -| **NATS JetStream** | (external) | KV `job-queue`, Stream `JOBS`, KV `job-responses` | -| **Agent / Provider Layer** | `internal/agent/`, `internal/provider/` | Consumes jobs from NATS and executes providers | +| **NATS JetStream** | (external) | KV `job-queue`, Stream `JOBS`, KV `job-responses`, KV `agent-facts` | +| **Agent / Provider Layer** | `internal/agent/`, `internal/provider/` | Consumes jobs, executes providers, publishes system facts | ```mermaid graph TD diff --git a/docs/docs/sidebar/features/node-management.md b/docs/docs/sidebar/features/node-management.md index 03a2ed0c..ebd10147 100644 --- a/docs/docs/sidebar/features/node-management.md +++ b/docs/docs/sidebar/features/node-management.md @@ -15,20 +15,26 @@ OSAPI separates agent fleet discovery from node system queries: - **Agent** commands (`agent list`, `agent get`) read directly from the NATS KV heartbeat registry. They show which agents are online, their labels, and - lightweight metrics from the last heartbeat. No jobs are created. + lightweight metrics from the last heartbeat. No jobs are created. Agents + also expose typed **system facts** (architecture, kernel version, FQDN, + CPU count, network interfaces, service manager, package manager) gathered + every 60 seconds via providers and stored in a separate `agent-facts` KV + bucket with a 5-minute TTL. The API merges registry and facts data into a + single `AgentInfo` response. - **Node** commands (`node hostname`, `node status`) dispatch jobs to agents that execute system commands and return detailed results (disk usage, full memory breakdown, etc.). ## What It Manages -| Resource | Description | -| -------- | -------------------------------------------------- | -| Hostname | System hostname | -| Status | Uptime, OS name and version, kernel, platform info | -| Disk | Per-mount usage (total, used, free, percent) | -| Memory | RAM and swap usage (total, used, free, percent) | -| Load | 1-, 5-, and 15-minute load averages | +| Resource | Description | +| ------------ | -------------------------------------------------------- | +| Hostname | System hostname | +| Status | Uptime, OS name and version, kernel, platform info | +| Disk | Per-mount usage (total, used, free, percent) | +| Memory | RAM and swap usage (total, used, free, percent) | +| Load | 1-, 5-, and 15-minute load averages | +| System Facts | Architecture, kernel, FQDN, CPUs, NICs, service/pkg mgr | ## How It Works From d824d826c8ab903132e71532a6c8185cc95ee78a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 19:02:55 -0800 Subject: [PATCH 19/24] style: fix lint issues in facts implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add package comment to netinfo, rename unused params to _, fix goimports formatting. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/agent/facts_test.go | 10 +++++----- internal/job/types_public_test.go | 8 +++++++- internal/provider/network/netinfo/mocks/mocks.go | 8 +++++++- internal/provider/network/netinfo/netinfo.go | 1 + .../host/ubuntu_get_service_manager_public_test.go | 4 ++-- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/internal/agent/facts_test.go b/internal/agent/facts_test.go index 3366469e..864a2507 100644 --- a/internal/agent/facts_test.go +++ b/internal/agent/facts_test.go @@ -49,12 +49,12 @@ import ( type FactsTestSuite struct { suite.Suite - mockCtrl *gomock.Controller - mockJobClient *mocks.MockJobClient - mockFactsKV *mocks.MockKeyValue + mockCtrl *gomock.Controller + mockJobClient *mocks.MockJobClient + mockFactsKV *mocks.MockKeyValue mockHostProvider *hostMocks.MockProvider - mockNetinfo *netinfoMocks.MockProvider - agent *Agent + mockNetinfo *netinfoMocks.MockProvider + agent *Agent } func (s *FactsTestSuite) SetupTest() { diff --git a/internal/job/types_public_test.go b/internal/job/types_public_test.go index 188e814d..cf46c2f0 100644 --- a/internal/job/types_public_test.go +++ b/internal/job/types_public_test.go @@ -234,7 +234,13 @@ func (suite *TypesPublicTestSuite) TestAgentInfoFactsFieldsJSONRoundTrip() { ServiceMgr: "systemd", PackageMgr: "apt", Interfaces: []job.NetworkInterface{ - {Name: "eth0", IPv4: "10.0.0.1", IPv6: "fe80::1", MAC: "aa:bb:cc:dd:ee:ff", Family: "dual"}, + { + Name: "eth0", + IPv4: "10.0.0.1", + IPv6: "fe80::1", + MAC: "aa:bb:cc:dd:ee:ff", + Family: "dual", + }, }, Facts: map[string]any{ "custom": "value", diff --git a/internal/provider/network/netinfo/mocks/mocks.go b/internal/provider/network/netinfo/mocks/mocks.go index fdfc423b..065c37ae 100644 --- a/internal/provider/network/netinfo/mocks/mocks.go +++ b/internal/provider/network/netinfo/mocks/mocks.go @@ -36,7 +36,13 @@ func NewDefaultMockProvider(ctrl *gomock.Controller) *MockProvider { mock := NewMockProvider(ctrl) mock.EXPECT().GetInterfaces().Return([]job.NetworkInterface{ - {Name: "eth0", IPv4: "192.168.1.10", IPv6: "fe80::1", MAC: "00:11:22:33:44:55", Family: "dual"}, + { + Name: "eth0", + IPv4: "192.168.1.10", + IPv6: "fe80::1", + MAC: "00:11:22:33:44:55", + Family: "dual", + }, }, nil).AnyTimes() return mock diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go index b1562923..f61fa410 100644 --- a/internal/provider/network/netinfo/netinfo.go +++ b/internal/provider/network/netinfo/netinfo.go @@ -18,6 +18,7 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. +// Package netinfo provides network interface information. package netinfo import ( diff --git a/internal/provider/node/host/ubuntu_get_service_manager_public_test.go b/internal/provider/node/host/ubuntu_get_service_manager_public_test.go index 0b6648f6..0ee2b73c 100644 --- a/internal/provider/node/host/ubuntu_get_service_manager_public_test.go +++ b/internal/provider/node/host/ubuntu_get_service_manager_public_test.go @@ -47,7 +47,7 @@ func (suite *UbuntuGetServiceManagerPublicTestSuite) TestGetServiceManager() { { name: "when systemd detected", setupMock: func(u *host.Ubuntu) { - u.StatFn = func(name string) (os.FileInfo, error) { + u.StatFn = func(_ string) (os.FileInfo, error) { return nil, nil } }, @@ -57,7 +57,7 @@ func (suite *UbuntuGetServiceManagerPublicTestSuite) TestGetServiceManager() { { name: "when systemd not detected", setupMock: func(u *host.Ubuntu) { - u.StatFn = func(name string) (os.FileInfo, error) { + u.StatFn = func(_ string) (os.FileInfo, error) { return nil, os.ErrNotExist } }, From 0c0fd0abf1df33fd35a07fcb5acdc0fcc99b520f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 3 Mar 2026 19:08:31 -0800 Subject: [PATCH 20/24] test: achieve 100% coverage for netinfo and host providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AddrsFn to Netinfo struct to make address resolution mockable, enabling test cases for IPv4/IPv6/dual/no-address interfaces and non-IPNet address types. Add ExecNotFoundError.Error() assertion to the host package manager test table. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/provider/network/netinfo/netinfo.go | 6 +- .../network/netinfo/netinfo_public_test.go | 170 ++++++++++++++++++ .../ubuntu_get_package_manager_public_test.go | 27 ++- 3 files changed, 198 insertions(+), 5 deletions(-) diff --git a/internal/provider/network/netinfo/netinfo.go b/internal/provider/network/netinfo/netinfo.go index f61fa410..cd822170 100644 --- a/internal/provider/network/netinfo/netinfo.go +++ b/internal/provider/network/netinfo/netinfo.go @@ -30,12 +30,16 @@ import ( // Netinfo implements the Provider interface for network interface information. type Netinfo struct { InterfacesFn func() ([]net.Interface, error) + AddrsFn func(iface net.Interface) ([]net.Addr, error) } // New factory to create a new Netinfo instance. func New() *Netinfo { return &Netinfo{ InterfacesFn: net.Interfaces, + AddrsFn: func(iface net.Interface) ([]net.Addr, error) { + return iface.Addrs() + }, } } @@ -59,7 +63,7 @@ func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { MAC: iface.HardwareAddr.String(), } - addrs, err := iface.Addrs() + addrs, err := n.AddrsFn(iface) if err == nil { for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) diff --git a/internal/provider/network/netinfo/netinfo_public_test.go b/internal/provider/network/netinfo/netinfo_public_test.go index 8df5b513..6fed3644 100644 --- a/internal/provider/network/netinfo/netinfo_public_test.go +++ b/internal/provider/network/netinfo/netinfo_public_test.go @@ -43,6 +43,7 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() { tests := []struct { name string setupMock func() func() ([]net.Interface, error) + addrsFn func(iface net.Interface) ([]net.Addr, error) wantErr bool wantErrType error validateFunc func(result []job.NetworkInterface) @@ -102,6 +103,171 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() { suite.Empty(result) }, }, + { + name: "when interface has IPv4 address", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{IP: net.ParseIP("192.168.1.10"), Mask: net.CIDRMask(24, 32)}, + }, nil + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Equal("192.168.1.10", result[0].IPv4) + suite.Empty(result[0].IPv6) + suite.Equal("inet", result[0].Family) + }, + }, + { + name: "when interface has IPv6 address", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)}, + }, nil + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Empty(result[0].IPv4) + suite.Equal("fe80::1", result[0].IPv6) + suite.Equal("inet6", result[0].Family) + }, + }, + { + name: "when interface has both IPv4 and IPv6 addresses", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{IP: net.ParseIP("10.0.0.5"), Mask: net.CIDRMask(24, 32)}, + &net.IPNet{IP: net.ParseIP("fe80::1"), Mask: net.CIDRMask(64, 128)}, + }, nil + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Equal("10.0.0.5", result[0].IPv4) + suite.Equal("fe80::1", result[0].IPv6) + suite.Equal("dual", result[0].Family) + }, + }, + { + name: "when interface has no addresses", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{}, nil + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Empty(result[0].IPv4) + suite.Empty(result[0].IPv6) + suite.Empty(result[0].Family) + }, + }, + { + name: "when AddrsFn returns error", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return nil, assert.AnError + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Empty(result[0].IPv4) + suite.Empty(result[0].IPv6) + suite.Empty(result[0].Family) + }, + }, + { + name: "when addr is not *net.IPNet", + setupMock: func() func() ([]net.Interface, error) { + return func() ([]net.Interface, error) { + return []net.Interface{ + { + Index: 2, + MTU: 1500, + Name: "eth0", + HardwareAddr: net.HardwareAddr{0x00, 0x11, 0x22, 0x33, 0x44, 0x55}, + Flags: net.FlagUp | net.FlagBroadcast, + }, + }, nil + } + }, + addrsFn: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPAddr{IP: net.ParseIP("192.168.1.10")}, + }, nil + }, + wantErr: false, + validateFunc: func(result []job.NetworkInterface) { + suite.Require().Len(result, 1) + suite.Empty(result[0].IPv4) + suite.Empty(result[0].IPv6) + suite.Empty(result[0].Family) + }, + }, { name: "when net.Interfaces errors", setupMock: func() func() ([]net.Interface, error) { @@ -122,6 +288,10 @@ func (suite *GetInterfacesPublicTestSuite) TestGetInterfaces() { n.InterfacesFn = tc.setupMock() } + if tc.addrsFn != nil { + n.AddrsFn = tc.addrsFn + } + got, err := n.GetInterfaces() if tc.wantErr { diff --git a/internal/provider/node/host/ubuntu_get_package_manager_public_test.go b/internal/provider/node/host/ubuntu_get_package_manager_public_test.go index 1c5d88d5..dc8a77b5 100644 --- a/internal/provider/node/host/ubuntu_get_package_manager_public_test.go +++ b/internal/provider/node/host/ubuntu_get_package_manager_public_test.go @@ -38,10 +38,11 @@ func (suite *UbuntuGetPackageManagerPublicTestSuite) TearDownTest() {} func (suite *UbuntuGetPackageManagerPublicTestSuite) TestGetPackageManager() { tests := []struct { - name string - setupMock func(u *host.Ubuntu) - want interface{} - wantErr bool + name string + setupMock func(u *host.Ubuntu) + want interface{} + wantErr bool + validateFunc func(got string) }{ { name: "when apt detected", @@ -92,6 +93,20 @@ func (suite *UbuntuGetPackageManagerPublicTestSuite) TestGetPackageManager() { want: "unknown", wantErr: false, }, + { + name: "when ExecNotFoundError formats message", + setupMock: func(u *host.Ubuntu) { + u.LookPathFn = func(file string) (string, error) { + return "", &host.ExecNotFoundError{Name: file} + } + }, + want: "unknown", + wantErr: false, + validateFunc: func(_ string) { + err := &host.ExecNotFoundError{Name: "apt"} + suite.Equal("executable file not found: apt", err.Error()) + }, + }, } for _, tc := range tests { @@ -111,6 +126,10 @@ func (suite *UbuntuGetPackageManagerPublicTestSuite) TestGetPackageManager() { suite.NoError(err) suite.Equal(tc.want, got) } + + if tc.validateFunc != nil { + tc.validateFunc(got) + } }) } } From a637b189957bf93548f391437e47e89e01687bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 4 Mar 2026 16:58:04 -0800 Subject: [PATCH 21/24] feat(cli): display all agent facts in agent get output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add architecture, kernel, CPUs, FQDN, service/package manager, and network interfaces to `agent get` CLI output. Add mergeFacts invalid JSON test for 100% coverage. Update local config with facts KV bucket. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_agent_get.go | 40 ++++ docs/docs/gen/api/get-agent-details.api.mdx | 193 +++++++++++++++++- docs/docs/gen/api/list-active-agents.api.mdx | 193 +++++++++++++++++- .../sidebar/architecture/job-architecture.md | 7 +- .../architecture/system-architecture.md | 14 +- docs/docs/sidebar/features/node-management.md | 26 +-- .../sidebar/usage/cli/client/agent/get.md | 36 ++-- docs/docs/sidebar/usage/configuration.md | 8 +- docs/plans/2026-03-03-agent-facts-design.md | 98 +++++---- docs/plans/2026-03-03-agent-facts.md | 156 ++++++++------ internal/cli/ui.go | 10 +- internal/cli/ui_public_test.go | 24 ++- internal/job/client/query_public_test.go | 34 +++ 13 files changed, 667 insertions(+), 172 deletions(-) diff --git a/cmd/client_agent_get.go b/cmd/client_agent_get.go index 7f136064..52232ccc 100644 --- a/cmd/client_agent_get.go +++ b/cmd/client_agent_get.go @@ -84,6 +84,22 @@ func displayAgentGetDetail( cli.PrintKV("Last Seen", cli.FormatAge(time.Since(data.RegisteredAt))+" ago") } + if data.Architecture != "" { + cli.PrintKV("Arch", data.Architecture) + } + + 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.OneMin, data.LoadAverage.FiveMin, data.LoadAverage.FifteenMin, @@ -98,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() { diff --git a/docs/docs/gen/api/get-agent-details.api.mdx b/docs/docs/gen/api/get-agent-details.api.mdx index bd2bf18b..5769d8d5 100644 --- a/docs/docs/gen/api/get-agent-details.api.mdx +++ b/docs/docs/gen/api/get-agent-details.api.mdx @@ -5,7 +5,7 @@ description: "Get detailed information about a specific agent by hostname." sidebar_label: "Get agent details" hide_title: true hide_table_of_contents: true -api: eJztWN9v2zgM/lcEPt0Bbuq0zV3ntxxuHXq33YquxR6KoJBtJtYaS55E9ZoL/L8fKDuu86NtgG4PA/YUx6Y+kh8pkdQScnSZVRUpoyGBd0giR5JqjrlQempsKfmTkKnxJKRwFWZqqjIhZ6hJpAtRGEdaljiACEyFNsif55DADGnMUn8GQAcRkJw5SG4gvL79ILWcYcmP44vz24B420E4mETgMPNW0QKSmyX8gdKiHXsqGCOIJxZlDpN6EkElrSyR0LogzCZBAivrIALFDlaSCojA4levLOaQkPUYgcsKLCUkS6BFxescWaVnUEcbBF0V2HkszFRQgS0VZIRFsgrvcQBskEVXGe3QMexRHPPPOligoeXbMX+Z0YSaWFJW1VxlgYnDL47Fl9tWmvQLZgQRVJZ5I9Uo65ze8mdvdwbsuiNJ3u1CQe1LDsIlynwBEfxjqHmc7NKQeWvZ0wZvW89cpjh3u/ySea4YSM4v1jx8IUp/4+LgXs49igZaZEZP1cxbzIXRG9otzpQjtJjfStrlbLMLIIFcEh6QCtm0rvBzgT1YMZeOhMWpRVfwRiInCpSWUpQds5a+ocLKmgydEy1u0GHcLe/gXbSuY31stpyeCbdwhGV/4w+2kitXbGfqm8X7JFg/ud4r7R9EH4M14IMsqznDXKdek2fz79G6vVW0wvtqOYoH8QnUdf8guFn37NGASQSkKKz7+OlcT81lu7HZSl+F8OxlZMtus6RJfCPzW3mPVs7w5Tj1MHihaBc6MTVWDCMxioTUuRiORKm0J3TbwRuWqs+o9mWKdkvR+x56AGdOQ04PW+g1OuPB8VEdweiV2Gtm98CPTusIhq9FHz4JP9rMg0BS689Kcy8JWMe4UdHPhBJLYxcvR/FDkBPesYnP7jQyJOc9QKUJZzucvmI50egXSot0senl6fHp6W8x8zi1iHtAnlnEZxGP4je/D0ccdu+YtRcRrx3mzyKeDN+cHMcnm8FoSGgNb7X1gtGw+RiHjdW98t8Ws97aUH55Q0Nd88KTeLhdo6+19FQYq/7DXByI8cW5uMOF6JR8s6KN1hr78kEyFr3/q/MurBVUSBImC9U2Xz/wzpqOrtejtMV40BTPpkF7WXlXjlddS9sYdkbsVJt7ZNUa6V9j7wQff8Y3hTAz+T75eNU5yQvWlIzimIO3CupblurlQxPY4+3AnhmbqjxHLQ7EuXZ+OlWZCrUUbamcCy3oz+j+CNE9eaq11obE1Hj9c5v+CIEc7ZqRguCKDu5R5Xcamn4G9jsFNvRGVJj2SoCJ5yE8gcMQysPlqkjXXKXR3jcjfG/6/8QhbKLUvwPorC6IKmiHeP6fBiGI2oez1Tj11+er0CF0Y1G/ERCP1xFc5XsDQALDQTyIma7KOCplyKv2goFvTNZScpO75WOCvvZ6pXWX8IEOq7lUOkwgNvSIDafttQhEkHStzyQKbRB/XC5T6fDazuuaX3/1yB0rU30vrZIps3ETRjx+ziGZyrnDZxz65bLtg34Ve96MPOFF+1Jqvk0IozskABHc4aJ/i1NP6ggKlDnaYGnzeZxlWFFv4dY5wPcxXRK+e3vFVwvrObSRMwF9p1HLZSNxZe5Q13VnI/F/NrCu/wfaZa8c +api: eJztWEtv2zgQ/isET7uAokiJnU10y3abIrt9BG2CHoLAoMWRzUYiFXLkxmvovy+Gkm3JdhIv2h4K9GRZmvnmLX2cBZfgUqtKVEbzhL8BZBJQqBwkUzozthD0iImxqZAJ5kpIVaZSJiagkY3nbGocalFAyANuSrBe/lLyhE8Az0nqLw/oeMBRTBxPbrm/PXontJhAQZfnV5cjjzhaQTh+F3AHaWUVznlyu+B/grBgzyucEoYXTywIye/qu4CXwooCEKzzwuQST/jSOx5wRQGWAqc84BYeKmVB8gRtBQF36RQKwZMFx3lJeg6t0hNeBxsJup7CKmJmMoZTaFOBhllAq2AGISeHLLjSaAeOYI+iiH76YD4Nbb4d5S81GkEjSYqyzFXqM3H4xZH4YttLM/4CKfKAl5byhqoxtgp6K569wwkpdIcCK7cLBXRVUBE+gpBzHvD3BpvLu10W0spairTB27aTizHkbldcQkpFQCK/6kX4QpX+gfnBTOQVsAaapUZnalJZkMzoDesWJsohWJAjgbuCbaaAJ1wKhANUvpv6Bj9PoQPLcuGQWcgsuCkNEjo2BWFxDGKVWYvf0WBpTQrOsRbX2zBuRBO8K619rA/NyOkJc3OHUHQHP9xqLqnIz3HVKO/TYN3meqt09ci6GGQBHkVR5gRzM640VuT+DKzb20QrvK+VoyiMBryuuy+C235kawfuAo4Kvd6HT5c6Mx/bwSYvq9KXZy8n2+w2Kk3jGyFHYgZWTODlOnUwSJG1io5lxrI4YMOACS1ZPGSF0hWC2y5eXKhuRnVVjMFuGXrbQffglFPf03EL3UtnFB4f1QEffiN2z+0O+NFpHfD4W9HjJ+GHm33gk9TGs7TcaQKycd6Y6HZCAYWx85er+M7LscqRi89OGhoUeQdQaYTJjqCvSY419pnSbDzfjPL0+PT0JKI8ZhZgD8gLC/As4lF09kc8pLJXjrL2IuKNA/ks4iA+GxxHg81iNEloHW+tdYrRZLNbB2HTqUJIsbJ7zOWrqxvW1ei/J0QhTwYEeg9WQz7a+5304RNrVJZvpj7uMIyHYXRwFh9MQINVKdlIy2qUmkrjHtl879ufXne5mahU5OzV1c1GPqnaD3IPZy+qPJ+zh0rkKlMgmTSFUJotSd3a7a8wPojisL0Rpqbw3zKwM5XCqJjYl21daoXtS6yP3dyTBFiK9F5M9gS8aoRZ4cmk3ahfiQRIWbSZSHvcQVgriLoohGIn9+gP41N8am0McBp5a+Vs8LxkfHYUxienYRzGS42T5zUyOI2SJPbvGZE+LxtFSRwnR0fJ8XEyGCTDIWllolD5fI/6XDEhpSUu0aj0EyorP4xL+qc0oGfWgCcERU/vNifYZ64zsu8Bvxp7f7ksynp4vZ8p/g8m2LD3fgivHxG0BMk8FsusKdham6jSTEmwLtwiAJ3DQkt9O257sk6ff17XpDiI4m1Gf6NFhVNj1b8g2QE7v7pk9zBnKyPfjeKDtWaP6Thnnf9LduR1GU4FMpN6bi77Vb5ozn+dE01L3cOGajfHuZeNr5Pe6rTHyJUTO83KCsi0btqEEVkyVUObUyP3+Xpdr4IkhZ6RYRRR8ZZFfU1SnQZsCnu8XdgLY8dKStDsgF1qV2WZSpVn3mAL5Zw/sP6q7s9Q3cFTB3FtkGWm0r/G9Gco5HDXRsULLtNBJ1rxg1Ysvwr7gwrrT1I4Ne0CkRJPK7uEH/pSHi6WH+maN9SzWfh1doWfqIRNlbobw5XXU8SStys/+j/2QjxoLy6Wy5e/P197hrBaonSJAFsvL+kr31kXJDwOo9DzutI4LIRe00e/X+215GbuFusG/dZlbBsuwiMelrlQ2u8rrD9RNjltl6g84MmK+twFngbRw8ViLBzc2Lyu6fZDBXS+pVTPhFViTNm49QshupY8yUTuNulYN6DfPrY86He25x71iSiWFF4TgfeLPp5wTse0eXfnW9/VAZ+CkGC9p83j8zSFEjuKW+8B2t6umvDN62uin/0e2ugZj77TqcWikbg296DreuUj0n9ysK7/A+haFv8= sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -402,6 +402,195 @@ Get detailed information about a specific agent by hostname. schema={{"type":"integer","description":"Used memory in bytes.","example":4194304}} > + + + + + + + + + + + + + + + + +
+ + + + interfaces + + object[] + + +
+
  • +
    + Array [ +
    +
  • + + + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
    + + + + facts + + object + + +
    +
    + + + Extended facts from additional providers. + + +
    +
    @@ -413,7 +602,7 @@ Get detailed information about a specific agent by hostname. value={"Example (from schema)"} > diff --git a/docs/docs/gen/api/list-active-agents.api.mdx b/docs/docs/gen/api/list-active-agents.api.mdx index 880e0ac0..e5e56118 100644 --- a/docs/docs/gen/api/list-active-agents.api.mdx +++ b/docs/docs/gen/api/list-active-agents.api.mdx @@ -5,7 +5,7 @@ description: "Discover all active agents in the fleet." sidebar_label: "List active agents" hide_title: true hide_table_of_contents: true -api: eJztV1Fv2zYQ/isEnxVHTuIt9ZuHNkO2dA1aB30IDIMSzxYbiVTJoxfP0H8fjpIdSfYSF02BPfTJss37vrv7Try7DZfgUqtKVEbzMX+rXGpWYJnIcyZSVCtgYgkaHVOaYQZskQPggEccxdLx8T2f0N/z90KLJRT0OLm9ngebuSnBCkJ2fBZxB6m3Ctd8fL/hv4GwYCceM8IIx8cWhOSzahZxC6402oHj4w0/i2P66Dp6oxwys+j6SG6lRiNoJAtRlrlKgwOnXxyZbbhLMygEPeG6BD7mJvkCKfKIl5bcRVWT1oCtc8JaseYRVwiFe9k+Mw61KKB10qFVesmjXiTTDNj2NEVESQ7sA15F3KFA7w6hgPYF5e4jCEmO/WWwfpwdYki9taCR1Xj7PLlIID8Yl5BSEZDIbzsRdv2p+qR/wvpkJXIPrIZmqdELtfQWJDO6x25hqRyCBTkXeCjYhbEF/cOlQDhBVcBeHj9n0IJluXDILCwsuAwkU+hYBsJiAmKXWYuvSFhak4JzrMENHMbNlV6YQ2ntYn2o3xS9ZG7tEApGZuSBMnqwV1xSkZ+Jr42PKbB2cd0o7R9ZG4MY4FEUZU4wd4nX6Mn9FVh3NEVz+FiWs3gQX/AqiP/VKwuSirkT2ZMDs4ijwmD34dO1XpiPzQVBXvoyyHOUk012a5O68I2Qc7ECK5bwsk4tDDJkjaFjC2PZMGKjiAkt2XDECqU9gtsXb1iodka1LxKwe0Q3LfQATjkNNT1soDvpjAfnZ1XER9+J3XG7BX52WUV8+L3ow/+EH/XrICSpiWfL3CoC4pjUFO1KKKAwdv2yiu/DOeYdufjsm4YGRd4CVBpheSDoKZ1jNT91ymTdj/Ly/PLyl5jyuLAAR0BeWYBnEc/iN78ORyS7d5S1FxHvHMhnES+Gby7O44u+GHUSGscbtpYYdTafdOhZ7zrhrpm1bMPwQC90MPu2bNf1d2AI6HvQ9PItfruOlMPggmu5T+YX8XB/5rjTwmNmrPoHJDthk9tr9gBrtqN6teEDrDX25Qttwlrft/dusGWYCWQmDV1fdi/eK6FykAwNs4BWwQqaoWBQN3EUKj84cPTId2MBa2yYSIzHJycO0koPRK0B/zb2gdE1bHzdkFMjj3kvprsgyaBDMorjUEaNuu/o1J6w5/vCXhmbKClBsxN2rZ1fLFSqQk8HWyjnwgT7U93/v7qjQ6tCOEiTSNhsaMZ69W3hp6Q/SNLQ1DEzko/5EkLiBa2M/DRoSD0FLE2JfHzfWjE/kW61NO1Fc+dqhliSbTjGxzwJh3jUPFxth/8/Pk9DN9kN8e22xZ52XuoFrXF1zIeDeBBTjkrjsBChmOp1sN5cOx2rn7HNU1l+00Jex4bwiKdlLpQOw7ENDbXOWtMKqQNSV6YfNptEOLizeVXRz1890ABFuVwJq0RC4d7PqohnICTYsLs/wJpykKZQkgJhy6MNuf8C0Sa/U+/3d1NaJrs69PIe0Lf7tl63sDeb+sTUPICuKh41TiB959Wsqqp/AaDGy7E= +api: eJztWEtv2zgQ/isEz44qOXY20S3bbRbZ7SNoEvRQGAYtjmw2EqmQIzdeQ/99MZRsS7KbuGgX2ENPlqWZb57kPNZcgkusKlAZzWP+h3KJWYJlIsuYSFAtgYk5aHRMaYYLYGkGgAEfcBRzx+PP/JI+T98JLeaQ0+PlzfXU80xNAVYQsuOTAXeQlFbhisef1/x3EBbsZYkLwvDksQUh+aSaDLgFVxjtwPF4zYdhSD9dRd8qh8ykXR1JrcRoBI3EIYoiU4lX4NUXR2xr7pIF5IKecFUAj7mZfYEE+YAXltRFVQutAVt0wlqx4gOuEHL3Mv/CONQihxalQ6v0nA96ltwtgG2oySJyspce8GrAHQos3SEU0GVOvvsIQpJi7w3Wj5NDEpLSWtDIarx9OZmYQXbQLiGlIiCR3XQs7OpT9YX+DauTpchKYDU0S4xO1by0IJnRPekW5sohWJBTgYeMTY3N6QuXAuEEVQ57fvy0gBYsy4RDZiG14BYgmULHFiAszkBsPWvxJwosrEnAOdbgehnGTZVOzSG3drE+1CdFz5lbOYScERtpoIwO9pJLKtJzVtbMxyRYO7neKl0+sTYGSYAnkRcZwdzPSo0lqb8E644W0RAfK2UYBuGIVz74j6WyICmZO5btFJgMOCr0fB9ur3VqPjYXBGlZFj48RynZeLdmqRPfCDkVS7BiDi/HqYVBjKxhdCw1lkUDNh4woSWLxixXukRw+8GLctX2qC7zGdg9QW9b6B6cfOpzOmqgO+4Mg9NhNeDjH8TuqN0CH55XAx79KHr0TfhxPw+8kxp7NpJbSUAyLmsR7UzIITd29XIU33k6VjpS8dmThgZF1gJUGmF+wOg7omO1fKqUs1XfyvPT8/OzkPyYWoAjIK8swLOIw/Dit2hMYS8dee1FxHsH8lnEUXQxOg1H/WDUTmgUb6S1glF7sx0HYZOFQkiwtEecy9c396zN0b0nRC7PRgT6AFZDNj36Tvpwy2qWzc3UxR0H0TgITy6ikzlosCohGUlRThNTajzCm+99+tN1l5m5SkTGXt/c9/xJ0X6URyh7VWbZij2WIlOpAsmkyYXS/truqv0VZidhFDQvgsTkvpaBXaoEpvncvizrWitsLrEudv1OEmAhkgcxPxLwpiZmue8BbS9+BRIgedGmIoEf6Km+1U/thAEuQi+tWI6ep4wuhkF0dh5EQbThOHueI4XzMI4jf8+I5HnaMIyjKB4O49PTeDSKx2PiSkWustUR8blhQkpLvUTN0nWoLP1h3LR/SgN5in7OCIq+Tvon2HuudWTfA3419uF6E5Td4fV6JvgdnSDaEvomvHlC0BIk81gstSZnO25qlZZKgnXBXgOw7Zu3rW9LbT9qUPn3bN93N+vtce2NDH0Nms5/g9+uOsqhV8G1/EXsozDan1DutShxYaz6ByQ7YZc31+wBVmwr6qeNKmCtOeKUXrLW/02X5nkZLgQyk/gZQXaz7UqoDCRDwyygVbCEZoQI6pYfhcoOjic94bvgNzxMzEyJOyUOipUlkGhdpyujps2UdfueGHlMFb3bGkkMHSHjMPRp1ET3DVHtBfZ0P7BXxs6UlKDZCbvWrkxTlSg/AYDNlXN+3v0V3f9/dMeHFguekOYWvwehieyn7xZ+hfQ/CqkfAXBhJI/53JfFQtCCib/yMeR1owSW9lathdQtxa0OTXsttVV1gVgQryfjMZ95Ij5oHq42q4K/Pt35arId+dtli+02ZFQLWsNtzKMgDHwXUhiHudC7Zqfec3UqVt9j611aftf6rrYN4QlfFZlQ2o/S1hfU2mtNKaQKSFWZXqzXM+Hg3mZVRa8fS6Bxi3y5FFaJGZn7eVIN+AKEBOs3fQ+wIh8kCRQUAb8T8n1h7wDR3m8bvT/f3FHD0Y1Dz+8efdNJ6lULe72uKe7MA+iq4oNGCaT/vJpUVfUvDGwzlA== sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -411,6 +411,195 @@ Discover all active agents in the fleet. schema={{"type":"integer","description":"Used memory in bytes.","example":4194304}} > +
    + + + + + + + + + + + + + + + +
    + + + + interfaces + + object[] + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
    + + + + facts + + object + + +
    +
    + + + Extended facts from additional providers. + + +
    +
    @@ -440,7 +629,7 @@ Discover all active agents in the fleet. value={"Example (from schema)"} > diff --git a/docs/docs/sidebar/architecture/job-architecture.md b/docs/docs/sidebar/architecture/job-architecture.md index 8dfa49b2..b4049bf7 100644 --- a/docs/docs/sidebar/architecture/job-architecture.md +++ b/docs/docs/sidebar/architecture/job-architecture.md @@ -82,6 +82,7 @@ graph LR - History: 5 versions 2. **job-responses**: Result storage + - Key format: `{sanitized_job_id}` - TTL: 24 hours - Used for agent-to-client result passing @@ -472,9 +473,9 @@ lightweight (status and metrics only) while facts carry richer, less frequently changing data. When the API serves an `AgentInfo` response (via `GET /node/{hostname}` or -`GET /node`), it merges data from both KV buckets — registry for status, -labels, and lightweight metrics, and facts for detailed system properties — -into a single unified response. +`GET /node`), it merges data from both KV buckets — registry for status, labels, +and lightweight metrics, and facts for detailed system properties — into a +single unified response. ## Operation Examples diff --git a/docs/docs/sidebar/architecture/system-architecture.md b/docs/docs/sidebar/architecture/system-architecture.md index c07477dc..d8e9cf6e 100644 --- a/docs/docs/sidebar/architecture/system-architecture.md +++ b/docs/docs/sidebar/architecture/system-architecture.md @@ -13,14 +13,14 @@ that can either hit the REST API directly or manage the job queue. The system is organized into six layers, top to bottom: -| Layer | Package | Role | -| -------------------------- | --------------------------------------- | ------------------------------------------------- | -| **CLI** | `cmd/` | Cobra command tree (thin wiring) | -| **SDK Client** | `osapi-sdk` (external) | OpenAPI-generated client used by CLI | -| **REST API** | `internal/api/` | Echo server with JWT middleware | -| **Job Client** | `internal/job/client/` | Business logic for job CRUD and status | +| Layer | Package | Role | +| -------------------------- | --------------------------------------- | ------------------------------------------------------------------- | +| **CLI** | `cmd/` | Cobra command tree (thin wiring) | +| **SDK Client** | `osapi-sdk` (external) | OpenAPI-generated client used by CLI | +| **REST API** | `internal/api/` | Echo server with JWT middleware | +| **Job Client** | `internal/job/client/` | Business logic for job CRUD and status | | **NATS JetStream** | (external) | KV `job-queue`, Stream `JOBS`, KV `job-responses`, KV `agent-facts` | -| **Agent / Provider Layer** | `internal/agent/`, `internal/provider/` | Consumes jobs, executes providers, publishes system facts | +| **Agent / Provider Layer** | `internal/agent/`, `internal/provider/` | Consumes jobs, executes providers, publishes system facts | ```mermaid graph TD diff --git a/docs/docs/sidebar/features/node-management.md b/docs/docs/sidebar/features/node-management.md index ebd10147..9a857ebf 100644 --- a/docs/docs/sidebar/features/node-management.md +++ b/docs/docs/sidebar/features/node-management.md @@ -15,25 +15,25 @@ OSAPI separates agent fleet discovery from node system queries: - **Agent** commands (`agent list`, `agent get`) read directly from the NATS KV heartbeat registry. They show which agents are online, their labels, and - lightweight metrics from the last heartbeat. No jobs are created. Agents - also expose typed **system facts** (architecture, kernel version, FQDN, - CPU count, network interfaces, service manager, package manager) gathered - every 60 seconds via providers and stored in a separate `agent-facts` KV - bucket with a 5-minute TTL. The API merges registry and facts data into a - single `AgentInfo` response. + lightweight metrics from the last heartbeat. No jobs are created. Agents also + expose typed **system facts** (architecture, kernel version, FQDN, CPU count, + network interfaces, service manager, package manager) gathered every 60 + seconds via providers and stored in a separate `agent-facts` KV bucket with a + 5-minute TTL. The API merges registry and facts data into a single `AgentInfo` + response. - **Node** commands (`node hostname`, `node status`) dispatch jobs to agents that execute system commands and return detailed results (disk usage, full memory breakdown, etc.). ## What It Manages -| Resource | Description | -| ------------ | -------------------------------------------------------- | -| Hostname | System hostname | -| Status | Uptime, OS name and version, kernel, platform info | -| Disk | Per-mount usage (total, used, free, percent) | -| Memory | RAM and swap usage (total, used, free, percent) | -| Load | 1-, 5-, and 15-minute load averages | +| Resource | Description | +| ------------ | ------------------------------------------------------- | +| Hostname | System hostname | +| Status | Uptime, OS name and version, kernel, platform info | +| Disk | Per-mount usage (total, used, free, percent) | +| Memory | RAM and swap usage (total, used, free, percent) | +| Load | 1-, 5-, and 15-minute load averages | | System Facts | Architecture, kernel, FQDN, CPUs, NICs, service/pkg mgr | ## How It Works diff --git a/docs/docs/sidebar/usage/cli/client/agent/get.md b/docs/docs/sidebar/usage/cli/client/agent/get.md index 0200bfbb..127ed5cc 100644 --- a/docs/docs/sidebar/usage/cli/client/agent/get.md +++ b/docs/docs/sidebar/usage/cli/client/agent/get.md @@ -27,24 +27,24 @@ $ osapi client agent get --hostname web-01 This command reads directly from the agent heartbeat registry -- no job is created. The data comes from the agent's most recent heartbeat write. -| Field | Description | -| ------------ | ---------------------------------------------------- | -| Hostname | Agent's configured or OS hostname | -| Status | `Ready` if present in registry | -| Labels | Key-value labels from agent config | -| OS | Distribution and version | -| Uptime | System uptime reported by the agent | -| Age | Time since the agent process started | -| Last Seen | Time since the last heartbeat refresh | -| Load | 1-, 5-, and 15-minute load averages | -| Memory | Total, used, and free RAM | -| Architecture | CPU architecture (e.g., amd64) | -| Kernel | OS kernel version | -| FQDN | Fully qualified domain name | -| CPUs | Number of logical CPUs | -| Service Mgr | Init system (e.g., systemd) | -| Package Mgr | Package manager (e.g., apt) | -| Interfaces | Network interfaces with IPv4, IPv6, MAC, and family | +| Field | Description | +| ------------ | --------------------------------------------------- | +| Hostname | Agent's configured or OS hostname | +| Status | `Ready` if present in registry | +| Labels | Key-value labels from agent config | +| OS | Distribution and version | +| Uptime | System uptime reported by the agent | +| Age | Time since the agent process started | +| Last Seen | Time since the last heartbeat refresh | +| Load | 1-, 5-, and 15-minute load averages | +| Memory | Total, used, and free RAM | +| Architecture | CPU architecture (e.g., amd64) | +| Kernel | OS kernel version | +| FQDN | Fully qualified domain name | +| CPUs | Number of logical CPUs | +| Service Mgr | Init system (e.g., systemd) | +| Package Mgr | Package manager (e.g., apt) | +| Interfaces | Network interfaces with IPv4, IPv6, MAC, and family | :::tip agent get vs. node status diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index fa715421..46f438f2 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -50,10 +50,10 @@ uppercased: | `nats.registry.ttl` | `OSAPI_NATS_REGISTRY_TTL` | | `nats.registry.storage` | `OSAPI_NATS_REGISTRY_STORAGE` | | `nats.registry.replicas` | `OSAPI_NATS_REGISTRY_REPLICAS` | -| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | -| `nats.facts.ttl` | `OSAPI_NATS_FACTS_TTL` | -| `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | -| `nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | +| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | +| `nats.facts.ttl` | `OSAPI_NATS_FACTS_TTL` | +| `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | +| `nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | | `telemetry.tracing.enabled` | `OSAPI_TELEMETRY_TRACING_ENABLED` | | `telemetry.tracing.exporter` | `OSAPI_TELEMETRY_TRACING_EXPORTER` | | `telemetry.tracing.otlp_endpoint` | `OSAPI_TELEMETRY_TRACING_OTLP_ENDPOINT` | diff --git a/docs/plans/2026-03-03-agent-facts-design.md b/docs/plans/2026-03-03-agent-facts-design.md index bbd470e4..93ba6b34 100644 --- a/docs/plans/2026-03-03-agent-facts-design.md +++ b/docs/plans/2026-03-03-agent-facts-design.md @@ -9,7 +9,7 @@ execution — "only run on Ubuntu hosts", "skip hosts with < 4GB RAM", "group hosts by OS distribution". Today the orchestrator can only target by hostname or label. It can't make -decisions based on what a host *is* (architecture, kernel, network interfaces, +decisions based on what a host _is_ (architecture, kernel, network interfaces, cloud region). ## Design @@ -18,48 +18,51 @@ cloud region). **Phase 1 — Built-in facts (cheap, always collected):** -| Category | Facts | Source | -|----------|-------|--------| -| System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `host.Provider` extensions | -| Hardware | cpu_count | `host.Provider` extension | -| Network | interfaces (name, ipv4, ipv6, mac) | New `netinfo.Provider` | +| Category | Facts | Source | +| -------- | -------------------------------------------------------- | -------------------------- | +| System | architecture, kernel_version, fqdn, service_mgr, pkg_mgr | `host.Provider` extensions | +| Hardware | cpu_count | `host.Provider` extension | +| Network | interfaces (name, ipv4, ipv6, mac) | New `netinfo.Provider` | **Phase 2 — Additional providers (opt-in):** -| Provider | Facts | Source | -|----------|-------|--------| -| Cloud | instance_id, region, instance_type, public_ip | Cloud metadata endpoints (AWS/GCP/Azure) | -| Local | arbitrary key-value data | JSON/YAML files in `/etc/osapi/facts.d/` | +| Provider | Facts | Source | +| -------- | --------------------------------------------- | ---------------------------------------- | +| Cloud | instance_id, region, instance_type, public_ip | Cloud metadata endpoints (AWS/GCP/Azure) | +| Local | arbitrary key-value data | JSON/YAML files in `/etc/osapi/facts.d/` | All Phase 1 facts are sub-millisecond calls. Phase 2 providers may involve network I/O (cloud metadata) or file I/O (local facts). ### Storage: Same API, Separate KV -The heartbeat serves two purposes today: liveness ("I'm alive") and state -("what I look like"). Splitting these lets each optimize independently. +The heartbeat serves two purposes today: liveness ("I'm alive") and state ("what +I look like"). Splitting these lets each optimize independently. **Registry KV (existing)** — lean heartbeat, frequent refresh: + - Hostname, labels, timestamps - 10s refresh, 30s TTL - ~200 bytes per agent **Facts KV (new `agent-facts` bucket)** — richer data, less frequent: + - OS, architecture, kernel, CPU, memory, interfaces, load, uptime - Extended facts from future providers - 60s refresh, 5min TTL - 1-10KB per agent (grows with future providers) -The API merges both KVs into a single `AgentInfo` response. Consumers never -know about the split. +The API merges both KVs into a single `AgentInfo` response. Consumers never know +about the split. ### Provider Pattern (Not a Plugin System) -Facts are gathered through the existing provider layer — the same pattern -used for `hostProvider.GetOSInfo()`, `loadProvider.GetAverageStats()`, etc. -There is no plugin system and no `Collector` interface. +Facts are gathered through the existing provider layer — the same pattern used +for `hostProvider.GetOSInfo()`, `loadProvider.GetAverageStats()`, etc. There is +no plugin system and no `Collector` interface. **Extend `host.Provider`** with new methods: + - `GetArchitecture() (string, error)` - `GetKernelVersion() (string, error)` - `GetFQDN() (string, error)` @@ -68,13 +71,14 @@ There is no plugin system and no `Collector` interface. - `GetPackageManager() (string, error)` **New `netinfo.Provider`** for network interface facts: + - `GetInterfaces() ([]NetworkInterface, error)` The facts writer calls these providers exactly like the heartbeat calls its providers — errors are non-fatal, the agent writes whatever data it gathered. -Future cloud metadata and local facts would be additional providers added -to the agent when needed, following the same pattern. +Future cloud metadata and local facts would be additional providers added to the +agent when needed, following the same pattern. ### Data Structure @@ -91,23 +95,24 @@ type FactsRegistration struct { } ``` -The `Facts map[string]any` field is reserved for future providers that -produce unstructured data (cloud metadata, local facts). +The `Facts map[string]any` field is reserved for future providers that produce +unstructured data (cloud metadata, local facts). ### API Exposure No new endpoints. Existing `GET /agent` and `GET /agent/{hostname}` return -`AgentInfo` which includes all facts. The API server reads both the registry -and facts KV buckets and merges them. +`AgentInfo` which includes all facts. The API server reads both the registry and +facts KV buckets and merges them. -The orchestrator calls `Agent.List()` once and gets everything needed for -host filtering — no second API call. +The orchestrator calls `Agent.List()` once and gets everything needed for host +filtering — no second API call. ### Orchestrator Integration Facts enable four key patterns in the orchestrator DSL: **1. Pre-routing host discovery (filter by facts):** + ```go hosts, _ := o.Discover(ctx, "_all", orchestrator.OS("Ubuntu"), @@ -117,6 +122,7 @@ hosts, _ := o.Discover(ctx, "_all", ``` **2. Fact-aware When guards:** + ```go o.CommandShell("_all", "apt upgrade -y"). WhenFact(func(f orchestrator.Facts) bool { @@ -125,6 +131,7 @@ o.CommandShell("_all", "apt upgrade -y"). ``` **3. Group-by-fact (multi-distro playbooks):** + ```go groups, _ := o.GroupByFact(ctx, "os.distribution") for distro, hosts := range groups { @@ -133,6 +140,7 @@ for distro, hosts := range groups { ``` **4. Facts in TaskFunc (custom logic):** + ```go o.TaskFunc("decide", func(ctx context.Context, r orchestrator.Results) (*sdk.Result, error) { agents, _ := r.ListAgents(ctx) @@ -159,13 +167,13 @@ agent: ### OSAPI (this repo) -1. `internal/job/types.go` — add `NetworkInterface`, `FactsRegistration`, - and new typed fields on `AgentInfo` -2. `internal/provider/node/host/types.go` — extend `Provider` interface - with `GetArchitecture`, `GetKernelVersion`, `GetFQDN`, `GetCPUCount`, +1. `internal/job/types.go` — add `NetworkInterface`, `FactsRegistration`, and + new typed fields on `AgentInfo` +2. `internal/provider/node/host/types.go` — extend `Provider` interface with + `GetArchitecture`, `GetKernelVersion`, `GetFQDN`, `GetCPUCount`, `GetServiceManager`, `GetPackageManager` -3. `internal/provider/node/host/ubuntu.go` (+ other platforms) — implement - new methods +3. `internal/provider/node/host/ubuntu.go` (+ other platforms) — implement new + methods 4. `internal/provider/network/netinfo/` — new provider for `GetInterfaces()` 5. `internal/agent/types.go` — add `factsKV` and `netinfoProvider` fields 6. `internal/agent/agent.go` — accept new provider, start facts loop @@ -183,20 +191,20 @@ agent: ### Documentation Updates -18. `docs/docs/sidebar/features/node-management.md` — update "Agent vs. - Node" section to explain facts, add facts to "What It Manages" table -19. `docs/docs/sidebar/architecture/system-architecture.md` — add - `agent-facts` KV bucket to component map, update NATS layers -20. `docs/docs/sidebar/architecture/job-architecture.md` — add section on - facts collection, describe 60s interval and KV storage +18. `docs/docs/sidebar/features/node-management.md` — update "Agent vs. Node" + section to explain facts, add facts to "What It Manages" table +19. `docs/docs/sidebar/architecture/system-architecture.md` — add `agent-facts` + KV bucket to component map, update NATS layers +20. `docs/docs/sidebar/architecture/job-architecture.md` — add section on facts + collection, describe 60s interval and KV storage 21. `docs/docs/sidebar/usage/configuration.md` — add `nats.facts` and `agent.facts` config sections, env var table, section reference -22. `docs/docs/sidebar/usage/cli/client/agent/list.md` — update example - output and column table with facts data -23. `docs/docs/sidebar/usage/cli/client/agent/get.md` — add facts fields - to output example and field table -24. `docs/docs/sidebar/usage/cli/client/health/status.md` — add - agent-facts bucket to KV buckets section +22. `docs/docs/sidebar/usage/cli/client/agent/list.md` — update example output + and column table with facts data +23. `docs/docs/sidebar/usage/cli/client/agent/get.md` — add facts fields to + output example and field table +24. `docs/docs/sidebar/usage/cli/client/health/status.md` — add agent-facts + bucket to KV buckets section ### SDK (osapi-sdk) @@ -214,8 +222,8 @@ agent: - NATS routing unchanged — `_all`, `_any`, labels work as before - No agent-side filtering — facts filter at publisher (orchestrator) side - No new API endpoints — facts are richer `AgentInfo` data -- Labels remain the primary routing mechanism; facts are for conditional - logic and discovery +- Labels remain the primary routing mechanism; facts are for conditional logic + and discovery - Existing heartbeat liveness behavior unchanged - No plugin system — facts are gathered through the provider layer diff --git a/docs/plans/2026-03-03-agent-facts.md b/docs/plans/2026-03-03-agent-facts.md index 39c16880..018113d2 100644 --- a/docs/plans/2026-03-03-agent-facts.md +++ b/docs/plans/2026-03-03-agent-facts.md @@ -1,12 +1,21 @@ # Agent Facts Collection System — Implementation Plan -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. -**Goal:** Add extensible fact collection to agents via the provider layer, stored in a separate KV bucket, merged into existing API responses, enabling orchestrator-side host filtering. +**Goal:** Add extensible fact collection to agents via the provider layer, +stored in a separate KV bucket, merged into existing API responses, enabling +orchestrator-side host filtering. -**Architecture:** Extend `host.Provider` with new fact methods (architecture, kernel, FQDN, CPU count, service manager, package manager). Create a new `netinfo.Provider` for network interfaces. The agent gathers facts on a 60s interval and writes them to a dedicated `agent-facts` KV bucket. The job client merges facts into `AgentInfo` when serving `ListAgents`/`GetAgent`. No plugin system — everything goes through providers. +**Architecture:** Extend `host.Provider` with new fact methods (architecture, +kernel, FQDN, CPU count, service manager, package manager). Create a new +`netinfo.Provider` for network interfaces. The agent gathers facts on a 60s +interval and writes them to a dedicated `agent-facts` KV bucket. The job client +merges facts into `AgentInfo` when serving `ListAgents`/`GetAgent`. No plugin +system — everything goes through providers. -**Tech Stack:** Go 1.25, NATS JetStream KV, gopsutil, oapi-codegen, testify/suite, gomock +**Tech Stack:** Go 1.25, NATS JetStream KV, gopsutil, oapi-codegen, +testify/suite, gomock **Design doc:** `docs/plans/2026-03-03-agent-facts-design.md` @@ -15,6 +24,7 @@ ### Task 1: Add Types — NetworkInterface, FactsRegistration, AgentInfo fields **Files:** + - Modify: `internal/job/types.go` - Test: `internal/job/types_public_test.go` (or appropriate existing test file) @@ -78,6 +88,7 @@ feat(job): add NetworkInterface and FactsRegistration types ### Task 2: Add Config Types — NATSFacts and AgentFacts **Files:** + - Modify: `internal/config/types.go` **Step 1: Add config structs** @@ -122,9 +133,11 @@ feat(config): add NATSFacts and AgentFacts config types ### Task 3: Extend host.Provider with Fact Methods **Files:** + - Modify: `internal/provider/node/host/types.go` — add methods to interface - Modify: `internal/provider/node/host/ubuntu.go` — implement for Ubuntu -- Modify: `internal/provider/node/host/mocks/types.gen.go` — update mock defaults +- Modify: `internal/provider/node/host/mocks/types.gen.go` — update mock + defaults - Test: `internal/provider/node/host/ubuntu_public_test.go` or similar **Step 1: Write failing tests** @@ -207,6 +220,7 @@ feat(provider): extend host.Provider with fact methods ### Task 4: Create netinfo.Provider for Network Interfaces **Files:** + - Create: `internal/provider/network/netinfo/types.go` - Create: `internal/provider/network/netinfo/netinfo.go` - Create: `internal/provider/network/netinfo/mocks/` (generate) @@ -214,9 +228,9 @@ feat(provider): extend host.Provider with fact methods **Step 1: Write failing test** -Test `GetInterfaces()` returns non-loopback, up interfaces with name, -IPv4, and MAC. Use table-driven pattern. Mock `net.Interfaces` via -a package-level function variable. +Test `GetInterfaces()` returns non-loopback, up interfaces with name, IPv4, and +MAC. Use table-driven pattern. Mock `net.Interfaces` via a package-level +function variable. **Step 2: Define the interface** @@ -292,8 +306,8 @@ func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { go generate ./internal/provider/network/netinfo/... ``` -Create `mocks/types.gen.go` with `NewDefaultMockProvider` returning -a stub interface list. +Create `mocks/types.gen.go` with `NewDefaultMockProvider` returning a stub +interface list. **Step 5: Run tests** @@ -312,17 +326,18 @@ feat(provider): add netinfo.Provider for network interface facts ### Task 5: Facts KV Bucket Infrastructure **Files:** + - Modify: `internal/cli/nats.go` — add `BuildFactsKVConfig` - Modify: `cmd/nats_helpers.go` — create facts KV in `setupJetStream` -- Modify: `cmd/api_helpers.go` — add `factsKV` to `natsBundle`, pass to - job client and metrics provider -- Modify: `internal/job/client/client.go` — add `FactsKV` to `Options` - and `factsKV` to `Client` +- Modify: `cmd/api_helpers.go` — add `factsKV` to `natsBundle`, pass to job + client and metrics provider +- Modify: `internal/job/client/client.go` — add `FactsKV` to `Options` and + `factsKV` to `Client` **Step 1: Add BuildFactsKVConfig** -In `internal/cli/nats.go`, add after `BuildRegistryKVConfig` (follow the -exact same pattern): +In `internal/cli/nats.go`, add after `BuildRegistryKVConfig` (follow the exact +same pattern): ```go func BuildFactsKVConfig( @@ -358,8 +373,8 @@ if appConfig.NATS.Facts.Bucket != "" { Add `factsKV jetstream.KeyValue` to `natsBundle` struct. -In `connectNATSBundle`, create the facts KV bucket (only if configured) -and pass it as `FactsKV` in `jobclient.Options`. +In `connectNATSBundle`, create the facts KV bucket (only if configured) and pass +it as `FactsKV` in `jobclient.Options`. Add `factsKV` to the returned `natsBundle`. @@ -368,6 +383,7 @@ In `newMetricsProvider`, add `b.factsKV` to the `KVInfoFn` buckets slice. **Step 4: Add to job client** In `internal/job/client/client.go`: + - Add `FactsKV jetstream.KeyValue` to `Options` - Add `factsKV jetstream.KeyValue` to `Client` struct - Assign in `New()`: `factsKV: opts.FactsKV,` @@ -389,11 +405,12 @@ feat(nats): add facts KV bucket infrastructure ### Task 6: Facts Writer in Agent **Files:** + - Create: `internal/agent/facts.go` - Create: `internal/agent/facts_test.go` (internal tests) - Modify: `internal/agent/types.go` — add `factsKV` and `netinfoProvider` -- Modify: `internal/agent/agent.go` — add params to `New()`, call - `startFacts()` in `Start()` +- Modify: `internal/agent/agent.go` — add params to `New()`, call `startFacts()` + in `Start()` - Modify: `internal/agent/factory.go` — create netinfo provider - Modify: `cmd/agent_helpers.go` — pass `factsKV` and netinfo provider @@ -411,17 +428,18 @@ netinfoProvider netinfo.Provider In `internal/agent/agent.go`, add `netinfoProvider netinfo.Provider` and `factsKV jetstream.KeyValue` parameters. Assign them. -In `internal/agent/factory.go`, add `netinfo.New()` to the provider -factory return values. Update `CreateProviders()` signature. +In `internal/agent/factory.go`, add `netinfo.New()` to the provider factory +return values. Update `CreateProviders()` signature. **Step 3: Write failing test for writeFacts** -Create `internal/agent/facts_test.go` (internal, `package agent`). -Use `FactsTestSuite` with gomock. Mock the `factsKV.Put()` call. -Verify the written data contains architecture, cpu_count, interfaces. -Follow the existing `heartbeat_test.go` pattern exactly. +Create `internal/agent/facts_test.go` (internal, `package agent`). Use +`FactsTestSuite` with gomock. Mock the `factsKV.Put()` call. Verify the written +data contains architecture, cpu_count, interfaces. Follow the existing +`heartbeat_test.go` pattern exactly. Test cases: + - `"when Put succeeds writes facts"` — verify JSON contains expected fields - `"when Put fails logs warning"` — verify no panic - `"when marshal fails logs warning"` — override `marshalJSON` variable @@ -536,12 +554,14 @@ feat(agent): add facts writer with provider-based collection ### Task 7: Merge Facts into ListAgents and GetAgent **Files:** + - Modify: `internal/job/client/query.go` — add `mergeFacts` helper - Test: `internal/job/client/query_public_test.go` **Step 1: Write failing test** Add test cases for facts merging. Test: + - Facts KV has data → fields appear in AgentInfo - Facts KV is nil → graceful degradation (fields empty) - Facts KV Get returns error → graceful degradation @@ -606,6 +626,7 @@ feat(job): merge facts KV data into ListAgents and GetAgent ### Task 8: OpenAPI Spec and API Handler **Files:** + - Modify: `internal/api/agent/gen/api.yaml` - Run: `go generate ./internal/api/agent/gen/...` - Modify: `internal/api/agent/agent_list.go` — update `buildAgentInfo` @@ -619,11 +640,11 @@ Add to `AgentInfo` properties in `api.yaml`: architecture: type: string description: CPU architecture. - example: "amd64" + example: 'amd64' kernel_version: type: string description: OS kernel version. - example: "5.15.0-91-generic" + example: '5.15.0-91-generic' cpu_count: type: integer description: Number of logical CPUs. @@ -631,15 +652,15 @@ cpu_count: fqdn: type: string description: Fully qualified domain name. - example: "web-01.example.com" + example: 'web-01.example.com' service_mgr: type: string description: Init system. - example: "systemd" + example: 'systemd' package_mgr: type: string description: Package manager. - example: "apt" + example: 'apt' interfaces: type: array items: @@ -658,13 +679,13 @@ NetworkInterfaceResponse: properties: name: type: string - example: "eth0" + example: 'eth0' ipv4: type: string - example: "192.168.1.10" + example: '192.168.1.10' mac: type: string - example: "00:11:22:33:44:55" + example: '00:11:22:33:44:55' required: - name ``` @@ -677,9 +698,9 @@ go generate ./internal/api/agent/gen/... **Step 3: Update buildAgentInfo** -In `internal/api/agent/agent_list.go`, add mappings for new fields after -the existing memory block. Map each non-zero/non-empty field. Map -`Interfaces` as `[]gen.NetworkInterfaceResponse`. +In `internal/api/agent/agent_list.go`, add mappings for new fields after the +existing memory block. Map each non-zero/non-empty field. Map `Interfaces` as +`[]gen.NetworkInterfaceResponse`. Check the generated field names in `agent.gen.go` and match them exactly. @@ -701,6 +722,7 @@ feat(api): expose agent facts in AgentInfo responses ### Task 9: Default Config **Files:** + - Modify: `osapi.yaml` **Step 1: Add defaults** @@ -739,27 +761,27 @@ chore: add default facts config to osapi.yaml ### Task 10: Update Documentation — Configuration Reference **Files:** + - Modify: `docs/docs/sidebar/usage/configuration.md` **Step 1: Add environment variable mappings** Add to the env var table: -| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | -| `nats.facts.ttl` | `OSAPI_NATS_FACTS_TTL` | -| `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | -| `nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | -| `agent.facts.interval` | `OSAPI_AGENT_FACTS_INTERVAL` | +| `nats.facts.bucket` | `OSAPI_NATS_FACTS_BUCKET` | | `nats.facts.ttl` | +`OSAPI_NATS_FACTS_TTL` | | `nats.facts.storage` | `OSAPI_NATS_FACTS_STORAGE` | | +`nats.facts.replicas` | `OSAPI_NATS_FACTS_REPLICAS` | | `agent.facts.interval` | +`OSAPI_AGENT_FACTS_INTERVAL` | **Step 2: Add section references** -Add `nats.facts` section reference table (Bucket, TTL, Storage, Replicas). -Add `agent.facts` section reference table (Interval). +Add `nats.facts` section reference table (Bucket, TTL, Storage, Replicas). Add +`agent.facts` section reference table (Interval). **Step 3: Update full YAML reference** -Add the `nats.facts` and `agent.facts` blocks to the full reference YAML -with inline comments. +Add the `nats.facts` and `agent.facts` blocks to the full reference YAML with +inline comments. **Step 4: Commit** @@ -772,33 +794,34 @@ docs: add facts configuration reference ### Task 11: Update Documentation — Feature and Architecture Pages **Files:** + - Modify: `docs/docs/sidebar/features/node-management.md` - Modify: `docs/docs/sidebar/architecture/system-architecture.md` - Modify: `docs/docs/sidebar/architecture/job-architecture.md` **Step 1: Update node-management.md** -- In "Agent vs. Node" section, add that agents now expose typed system - facts (architecture, kernel, FQDN, CPU count, network interfaces) in - addition to the basic heartbeat metrics. -- Clarify: facts are gathered every 60s via providers, stored in a - separate `agent-facts` KV bucket with a 5-minute TTL. +- In "Agent vs. Node" section, add that agents now expose typed system facts + (architecture, kernel, FQDN, CPU count, network interfaces) in addition to the + basic heartbeat metrics. +- Clarify: facts are gathered every 60s via providers, stored in a separate + `agent-facts` KV bucket with a 5-minute TTL. - Add a "System Facts" row to the "What It Manages" table. **Step 2: Update system-architecture.md** - Add `agent-facts` KV bucket to the NATS JetStream section alongside `agent-registry`. -- Update the component map table to mention facts in the Agent/Provider - layer description. +- Update the component map table to mention facts in the Agent/Provider layer + description. **Step 3: Update job-architecture.md** - Add a brief section on facts collection: - Facts are collected independently from the job system. - 60-second interval, separate KV bucket. - - Providers gather system facts (architecture, kernel, network - interfaces, etc.). + - Providers gather system facts (architecture, kernel, network interfaces, + etc.). - API merges registry + facts KV into a single AgentInfo response. **Step 4: Commit** @@ -812,35 +835,32 @@ docs: update feature and architecture pages with facts ### Task 12: Update Documentation — CLI Pages **Files:** + - Modify: `docs/docs/sidebar/usage/cli/client/agent/list.md` - Modify: `docs/docs/sidebar/usage/cli/client/agent/get.md` - Modify: `docs/docs/sidebar/usage/cli/client/health/status.md` **Step 1: Update agent list.md** -Update the example output to show any new facts-derived columns if the -CLI is updated to display them (e.g., ARCH column). If no CLI column -changes are planned for Phase 1, add a note that `--json` output -includes full facts data. +Update the example output to show any new facts-derived columns if the CLI is +updated to display them (e.g., ARCH column). If no CLI column changes are +planned for Phase 1, add a note that `--json` output includes full facts data. **Step 2: Update agent get.md** Add facts fields to the example output and field description table: -| Architecture | CPU architecture (e.g., amd64) | -| Kernel | OS kernel version | -| FQDN | Fully qualified domain name | -| CPUs | Number of logical CPUs | -| Service Mgr | Init system (e.g., systemd) | -| Package Mgr | Package manager (e.g., apt) | -| Interfaces | Network interfaces with IPv4 and MAC | +| Architecture | CPU architecture (e.g., amd64) | | Kernel | OS kernel version | +| FQDN | Fully qualified domain name | | CPUs | Number of logical CPUs | | +Service Mgr | Init system (e.g., systemd) | | Package Mgr | Package manager +(e.g., apt) | | Interfaces | Network interfaces with IPv4 and MAC | Update the example output block to show these new fields. **Step 3: Update health status.md** -Add `agent-facts` to the KV buckets section in the example output -(e.g., `Bucket: agent-facts (2 keys, 1.5 KB)`). +Add `agent-facts` to the KV buckets section in the example output (e.g., +`Bucket: agent-facts (2 keys, 1.5 KB)`). **Step 4: Commit** diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 9d431229..972defc1 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -584,7 +584,10 @@ func DisplayJobDetail( if len(resp.AgentStates) > 0 { stateRows := make([][]string, 0, len(resp.AgentStates)) for hostname, state := range resp.AgentStates { - stateRows = append(stateRows, []string{hostname, state.Status, state.Duration, state.Error}) + stateRows = append( + stateRows, + []string{hostname, state.Status, state.Duration, state.Error}, + ) } sections = append(sections, Section{ @@ -598,7 +601,10 @@ func DisplayJobDetail( if len(resp.Timeline) > 0 { timelineRows := make([][]string, 0, len(resp.Timeline)) for _, te := range resp.Timeline { - timelineRows = append(timelineRows, []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error}) + timelineRows = append( + timelineRows, + []string{te.Timestamp, te.Event, te.Hostname, te.Message, te.Error}, + ) } sections = append(sections, Section{ diff --git a/internal/cli/ui_public_test.go b/internal/cli/ui_public_test.go index 6ac26f9b..840d251e 100644 --- a/internal/cli/ui_public_test.go +++ b/internal/cli/ui_public_test.go @@ -572,23 +572,31 @@ func (suite *UIPublicTestSuite) TestHandleError() { wantInLog string }{ { - name: "when auth error logs api error with status code", - err: &osapi.AuthError{APIError: osapi.APIError{StatusCode: 403, Message: "insufficient permissions"}}, + name: "when auth error logs api error with status code", + err: &osapi.AuthError{ + APIError: osapi.APIError{StatusCode: 403, Message: "insufficient permissions"}, + }, wantInLog: "insufficient permissions", }, { - name: "when not found error logs api error with status code", - err: &osapi.NotFoundError{APIError: osapi.APIError{StatusCode: 404, Message: "job not found"}}, + name: "when not found error logs api error with status code", + err: &osapi.NotFoundError{ + APIError: osapi.APIError{StatusCode: 404, Message: "job not found"}, + }, wantInLog: "job not found", }, { - name: "when validation error logs api error with status code", - err: &osapi.ValidationError{APIError: osapi.APIError{StatusCode: 400, Message: "invalid input"}}, + name: "when validation error logs api error with status code", + err: &osapi.ValidationError{ + APIError: osapi.APIError{StatusCode: 400, Message: "invalid input"}, + }, wantInLog: "invalid input", }, { - name: "when server error logs api error with status code", - err: &osapi.ServerError{APIError: osapi.APIError{StatusCode: 500, Message: "internal server error"}}, + name: "when server error logs api error with status code", + err: &osapi.ServerError{ + APIError: osapi.APIError{StatusCode: 500, Message: "internal server error"}, + }, wantInLog: "internal server error", }, { diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go index 99d89e5a..6c07b949 100644 --- a/internal/job/client/query_public_test.go +++ b/internal/job/client/query_public_test.go @@ -1359,6 +1359,40 @@ func (s *QueryPublicTestSuite) TestListAgents() { s.Empty(agents[0].KernelVersion) }, }, + { + name: "when factsKV returns invalid JSON degrades gracefully", + useRegistryKV: true, + useFactsKV: true, + setupMockKV: func(kv *jobmocks.MockKeyValue) { + kv.EXPECT(). + Keys(gomock.Any()). + Return([]string{"agents.server1"}, nil) + + entry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + entry.EXPECT().Value().Return( + []byte( + `{"hostname":"server1","registered_at":"2026-01-01T00:00:00Z"}`, + ), + ) + kv.EXPECT(). + Get(gomock.Any(), "agents.server1"). + Return(entry, nil) + }, + setupMockFactsKV: func(kv *jobmocks.MockKeyValue) { + factsEntry := jobmocks.NewMockKeyValueEntry(s.mockCtrl) + factsEntry.EXPECT().Value().Return([]byte(`not valid json`)) + kv.EXPECT(). + Get(gomock.Any(), "facts.server1"). + Return(factsEntry, nil) + }, + expectedCount: 1, + validateFunc: func(agents []job.AgentInfo) { + s.Equal("server1", agents[0].Hostname) + s.Empty(agents[0].Architecture) + s.Zero(agents[0].CPUCount) + s.Nil(agents[0].Interfaces) + }, + }, } for _, tt := range tests { From 1c23cbd7faa43454756632055db4fe6a1580693c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 4 Mar 2026 17:08:48 -0800 Subject: [PATCH 22/24] chore: pin osapi-sdk to latest and remove replace directive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- go.mod | 4 +--- go.sum | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 03ed3848..e8c53583 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/oapi-codegen/runtime v1.2.0 github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86 github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 - github.com/osapi-io/osapi-sdk v0.0.0-20260301225211-54433f58dfd8 + github.com/osapi-io/osapi-sdk v0.0.0-20260305004213-6ad316fa4505 github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus/client_golang v1.23.2 github.com/samber/slog-echo v1.21.0 @@ -344,5 +344,3 @@ tool ( google.golang.org/protobuf/cmd/protoc-gen-go mvdan.cc/gofumpt ) - -replace github.com/osapi-io/osapi-sdk => ../osapi-sdk diff --git a/go.sum b/go.sum index b40b91c7..f8ce5a76 100644 --- a/go.sum +++ b/go.sum @@ -755,6 +755,8 @@ github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86 h1:ML0fdgr0M4 github.com/osapi-io/nats-client v0.0.0-20260222233639-d0822e0a4b86/go.mod h1:TQqODOjF2JuAOFrLtm1ItsMzPPAizKfHo+grOMuPDyE= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 h1:ELW1sTVBn5JIc17mHgd5fhpO3/7btaxJpxykG2Fe0U4= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848/go.mod h1:4rzeY9jiJF/+Ej4WNwqK5HQ2sflZrEs60GxQpg3Iya8= +github.com/osapi-io/osapi-sdk v0.0.0-20260305004213-6ad316fa4505 h1:J7Wv551BG39Ma9LLWxvZgsaWVNkP5TkteHzExSjt9e4= +github.com/osapi-io/osapi-sdk v0.0.0-20260305004213-6ad316fa4505/go.mod h1:5Y45ymBR4BcxJTOJ7WhqYTDHXxtlQRW7Sr3G52pfMdI= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= From 79e146ea262758ea857daffdacb3d38f53ffd2d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 4 Mar 2026 17:19:37 -0800 Subject: [PATCH 23/24] style: preallocate diskRows slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_node_status_get.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/client_node_status_get.go b/cmd/client_node_status_get.go index 803dfffd..72a97e7d 100644 --- a/cmd/client_node_status_get.go +++ b/cmd/client_node_status_get.go @@ -133,7 +133,7 @@ func displayNodeStatusDetail( )) } - diskRows := [][]string{} + diskRows := make([][]string, 0, len(data.Disks)) for _, disk := range data.Disks { diskRows = append(diskRows, []string{ disk.Name, From 4b59f88271fb02dfb2f9cee111611ae61c4244f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Wed, 4 Mar 2026 17:28:49 -0800 Subject: [PATCH 24/24] test: achieve 100% coverage for buildAgentInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/api/agent/agent_list_public_test.go | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/internal/api/agent/agent_list_public_test.go b/internal/api/agent/agent_list_public_test.go index ebcc8123..38f43a21 100644 --- a/internal/api/agent/agent_list_public_test.go +++ b/internal/api/agent/agent_list_public_test.go @@ -111,6 +111,70 @@ func (s *AgentListPublicTestSuite) TestGetAgent() { s.Equal(gen.Ready, r.Agents[1].Status) }, }, + { + name: "success with all facts fields", + mockAgents: []jobtypes.AgentInfo{ + { + Hostname: "server1", + Architecture: "x86_64", + KernelVersion: "6.1.0", + CPUCount: 8, + FQDN: "server1.example.com", + ServiceMgr: "systemd", + PackageMgr: "apt", + Interfaces: []jobtypes.NetworkInterface{ + { + Name: "eth0", + IPv4: "10.0.0.1", + IPv6: "fe80::1", + MAC: "aa:bb:cc:dd:ee:ff", + Family: "inet", + }, + {Name: "lo", IPv4: "127.0.0.1"}, + }, + Facts: map[string]any{"env": "prod"}, + }, + }, + validateFunc: func(resp gen.GetAgentResponseObject) { + r, ok := resp.(gen.GetAgent200JSONResponse) + s.True(ok) + s.Equal(1, r.Total) + + a := r.Agents[0] + s.Equal("server1", a.Hostname) + s.Require().NotNil(a.Architecture) + s.Equal("x86_64", *a.Architecture) + s.Require().NotNil(a.KernelVersion) + s.Equal("6.1.0", *a.KernelVersion) + s.Require().NotNil(a.CpuCount) + s.Equal(8, *a.CpuCount) + s.Require().NotNil(a.Fqdn) + s.Equal("server1.example.com", *a.Fqdn) + s.Require().NotNil(a.ServiceMgr) + s.Equal("systemd", *a.ServiceMgr) + s.Require().NotNil(a.PackageMgr) + s.Equal("apt", *a.PackageMgr) + s.Require().NotNil(a.Interfaces) + s.Len(*a.Interfaces, 2) + iface0 := (*a.Interfaces)[0] + s.Equal("eth0", iface0.Name) + s.Require().NotNil(iface0.Ipv4) + s.Equal("10.0.0.1", *iface0.Ipv4) + s.Require().NotNil(iface0.Ipv6) + s.Equal("fe80::1", *iface0.Ipv6) + s.Require().NotNil(iface0.Mac) + s.Equal("aa:bb:cc:dd:ee:ff", *iface0.Mac) + s.Require().NotNil(iface0.Family) + s.Equal(gen.NetworkInterfaceResponseFamily("inet"), *iface0.Family) + iface1 := (*a.Interfaces)[1] + s.Equal("lo", iface1.Name) + s.Nil(iface1.Ipv6) + s.Nil(iface1.Mac) + s.Nil(iface1.Family) + s.Require().NotNil(a.Facts) + s.Equal("prod", (*a.Facts)["env"]) + }, + }, { name: "success with no agents", mockAgents: []jobtypes.AgentInfo{},