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/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/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/client_agent_get.go b/cmd/client_agent_get.go index 62e413e9..52232ccc 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,67 @@ var clientAgentGetCmd = &cobra.Command{ resp, err := sdkClient.Agent.Get(ctx, hostname) if err != nil { - cli.LogFatal(logger, "failed to get agent details", err) + cli.HandleError(err, logger) + return } - switch resp.StatusCode() { - case http.StatusOK: - if jsonOutput { - fmt.Println(string(resp.Body)) - return - } - - if resp.JSON200 == nil { - cli.LogFatal(logger, "failed response", fmt.Errorf("agent response was nil")) - } - - displayAgentGetDetail(resp.JSON200) - case http.StatusUnauthorized: - cli.HandleAuthError(resp.JSON401, resp.StatusCode(), logger) - case http.StatusForbidden: - cli.HandleAuthError(resp.JSON403, resp.StatusCode(), logger) - case http.StatusNotFound: - cli.HandleUnknownError(resp.JSON404, resp.StatusCode(), logger) - default: - cli.HandleUnknownError(resp.JSON500, resp.StatusCode(), logger) + if jsonOutput { + fmt.Println(string(resp.RawJSON())) + return } + + displayAgentGetDetail(&resp.Data) }, } // displayAgentGetDetail renders detailed agent information in PrintKV style. func displayAgentGetDetail( - data *gen.AgentInfo, + data *osapi.Agent, ) { fmt.Println() - kvArgs := []string{"Hostname", data.Hostname, "Status", string(data.Status)} + kvArgs := []string{"Hostname", data.Hostname, "Status", data.Status} cli.PrintKV(kvArgs...) - if data.Labels != nil && len(*data.Labels) > 0 { + if len(data.Labels) > 0 { cli.PrintKV("Labels", cli.FormatLabels(data.Labels)) } - if data.OsInfo != nil { - cli.PrintKV("OS", data.OsInfo.Distribution+" "+cli.DimStyle.Render(data.OsInfo.Version)) + if data.OSInfo != nil { + cli.PrintKV("OS", data.OSInfo.Distribution+" "+cli.DimStyle.Render(data.OSInfo.Version)) + } + + if data.Uptime != "" { + cli.PrintKV("Uptime", data.Uptime) + } + + if !data.StartedAt.IsZero() { + cli.PrintKV("Age", cli.FormatAge(time.Since(data.StartedAt))) } - if data.Uptime != nil { - cli.PrintKV("Uptime", *data.Uptime) + if !data.RegisteredAt.IsZero() { + cli.PrintKV("Last Seen", cli.FormatAge(time.Since(data.RegisteredAt))+" ago") } - if data.StartedAt != nil { - cli.PrintKV("Age", cli.FormatAge(time.Since(*data.StartedAt))) + if data.Architecture != "" { + cli.PrintKV("Arch", data.Architecture) } - if data.RegisteredAt != nil { - cli.PrintKV("Last Seen", cli.FormatAge(time.Since(*data.RegisteredAt))+" ago") + if data.KernelVersion != "" { + cli.PrintKV("Kernel", data.KernelVersion) + } + + if data.CPUCount > 0 { + cli.PrintKV("CPUs", fmt.Sprintf("%d", data.CPUCount)) + } + + if data.Fqdn != "" { + cli.PrintKV("FQDN", data.Fqdn) } if data.LoadAverage != nil { cli.PrintKV("Load", fmt.Sprintf("%.2f, %.2f, %.2f", - data.LoadAverage.N1min, data.LoadAverage.N5min, data.LoadAverage.N15min, + data.LoadAverage.OneMin, data.LoadAverage.FiveMin, data.LoadAverage.FifteenMin, )+" "+cli.DimStyle.Render("(1m, 5m, 15m)")) } @@ -113,6 +114,30 @@ func displayAgentGetDetail( memParts = append(memParts, cli.FormatBytes(data.Memory.Free)+" free") cli.PrintKV("Memory", strings.Join(memParts, ", ")) } + + if data.ServiceMgr != "" { + cli.PrintKV("Service Mgr", data.ServiceMgr) + } + + if data.PackageMgr != "" { + cli.PrintKV("Package Mgr", data.PackageMgr) + } + + if len(data.Interfaces) > 0 { + for _, iface := range data.Interfaces { + parts := []string{} + if iface.IPv4 != "" { + parts = append(parts, iface.IPv4) + } + if iface.IPv6 != "" { + parts = append(parts, iface.IPv6) + } + if iface.MAC != "" { + parts = append(parts, cli.DimStyle.Render(iface.MAC)) + } + cli.PrintKV("Interface "+iface.Name, strings.Join(parts, " ")) + } + } } func init() { 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..72a97e7d 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)")) } @@ -149,17 +133,14 @@ 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), - }) - } + diskRows := make([][]string, 0, len(data.Disks)) + 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/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/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 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 3f69b4ba..b4049bf7 100644 --- a/docs/docs/sidebar/architecture/job-architecture.md +++ b/docs/docs/sidebar/architecture/job-architecture.md @@ -82,10 +82,17 @@ 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 +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 +460,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..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 | -| **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 | +| 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 | ```mermaid graph TD diff --git a/docs/docs/sidebar/features/node-management.md b/docs/docs/sidebar/features/node-management.md index 03a2ed0c..9a857ebf 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 diff --git a/docs/docs/sidebar/usage/cli/client/agent/get.md b/docs/docs/sidebar/usage/cli/client/agent/get.md index 3d17c239..127ed5cc 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) ``` diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 144e98c9..46f438f2 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 | 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..93ba6b34 --- /dev/null +++ b/docs/plans/2026-03-03-agent-facts-design.md @@ -0,0 +1,235 @@ +# 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 | `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/` | + +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. + +**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. + +### 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. + +**Extend `host.Provider`** with new methods: + +- `GetArchitecture() (string, error)` +- `GetKernelVersion() (string, error)` +- `GetFQDN() (string, error)` +- `GetCPUCount() (int, error)` +- `GetServiceManager() (string, error)` +- `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. + +### Data Structure + +```go +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"` +} +``` + +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. + +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' +``` + +## What Changes Where + +### 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`, + `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) + +25. Sync api.yaml, regenerate — `AgentInfo` gets new fields automatically + +### Orchestrator (osapi-orchestrator) + +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 + +- 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 +- No plugin system — facts are gathered through the provider layer + +## Phases + +- **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 new file mode 100644 index 00000000..018113d2 --- /dev/null +++ b/docs/plans/2026-03-03-agent-facts.md @@ -0,0 +1,916 @@ +# 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 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. + +**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, FactsRegistration, AgentInfo fields + +**Files:** + +- Modify: `internal/job/types.go` +- Test: `internal/job/types_public_test.go` (or appropriate existing test file) + +**Step 1: Write the failing test** + +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. + +**Step 2: Run test to verify it fails** + +```bash +go test -run TestFactsRegistration -v ./internal/job/... +``` + +Expected: FAIL — types undefined. + +**Step 3: Write minimal implementation** + +Add to `internal/job/types.go`: + +```go +// 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"` +} +``` + +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** + +```bash +go test -run TestFactsRegistration -v ./internal/job/... +``` + +**Step 5: Commit** + +``` +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`: + +```go +type NATSFacts struct { + Bucket string `mapstructure:"bucket"` + TTL string `mapstructure:"ttl"` + Storage string `mapstructure:"storage"` + Replicas int `mapstructure:"replicas"` +} +``` + +Add `Facts NATSFacts` field to the `NATS` struct. + +Add `AgentFacts` after `AgentConsumer`: + +```go +type AgentFacts struct { + Interval string `mapstructure:"interval"` +} +``` + +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 +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 4: Implement in Ubuntu provider** + +In `internal/provider/node/host/ubuntu.go`: + +- `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 +go test -v ./internal/provider/node/host/... +go build ./... +``` + +**Step 7: Commit** + +``` +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) +- 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 2: Define the interface** + +In `types.go`: + +```go +package netinfo + +import "github.com/retr0h/osapi/internal/job" + +type Provider interface { + GetInterfaces() ([]job.NetworkInterface, error) +} +``` + +**Step 3: Implement** + +In `netinfo.go`: + +```go +package netinfo + +import ( + "net" + + "github.com/retr0h/osapi/internal/job" +) + +type Netinfo struct{} + +func New() *Netinfo { return &Netinfo{} } + +var netInterfacesFn = net.Interfaces + +func (n *Netinfo) GetInterfaces() ([]job.NetworkInterface, error) { + ifaces, err := netInterfacesFn() + 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 +} +``` + +**Step 4: Generate mocks and add defaults** + +```bash +# Add generate.go with //go:generate directive +go generate ./internal/provider/network/netinfo/... +``` + +Create `mocks/types.gen.go` with `NewDefaultMockProvider` returning a stub +interface list. + +**Step 5: Run tests** + +```bash +go test -v ./internal/provider/network/netinfo/... +``` + +**Step 6: Commit** + +``` +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` + +**Step 1: Add BuildFactsKVConfig** + +In `internal/cli/nats.go`, add after `BuildRegistryKVConfig` (follow the exact +same pattern): + +```go +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 block (line ~165): + +```go +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: Wire into natsBundle and job client** + +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`. + +Add `factsKV` to the returned `natsBundle`. + +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,` + +**Step 5: Verify build** + +```bash +go build ./... +``` + +**Step 6: Commit** + +``` +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/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: + +```go +factsKV jetstream.KeyValue +netinfoProvider netinfo.Provider +``` + +**Step 2: Update New() and factory** + +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. + +**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. + +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** + +```bash +go test -run TestWriteFacts -v ./internal/agent/... +``` + +**Step 5: Implement facts.go** + +Create `internal/agent/facts.go`: + +```go +package agent + +// factsInterval controls the fact refresh period. +var factsInterval = 60 * time.Second + +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) + } + } + }() +} + +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", ...) + return + } + + key := factsKey(hostname) + if _, err := a.factsKV.Put(ctx, key, data); err != nil { + a.logger.Warn("failed to write facts", ...) + } +} + +func factsKey(hostname string) string { + return "facts." + job.SanitizeHostname(hostname) +} +``` + +**Step 6: Wire into Start()** + +In `internal/agent/server.go`, after `a.startHeartbeat(a.ctx, hostname)`: + +```go +a.startFacts(a.ctx, hostname) +``` + +**Step 7: Update cmd/agent_helpers.go** + +Pass `b.factsKV` and the netinfo provider to `agent.New()`. + +**Step 8: Run tests** + +```bash +go test -v ./internal/agent/... +go build ./... +``` + +**Step 9: Commit** + +``` +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 + +Follow existing test patterns in `query_public_test.go`. + +**Step 2: Run test to verify it fails** + +```bash +go test -run TestListAgentsWithFacts -v ./internal/job/client/... +``` + +**Step 3: Implement mergeFacts** + +Add to `internal/job/client/query.go`: + +```go +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 +} +``` + +Call `c.mergeFacts(ctx, &info)` in both `ListAgents` (after +`agentInfoFromRegistration`) and `GetAgent` (after building info). + +**Step 4: Run tests** + +```bash +go test -v ./internal/job/client/... +``` + +**Step 5: Commit** + +``` +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` +- Test: `internal/api/agent/agent_list_public_test.go` (or existing test) + +**Step 1: Extend OpenAPI spec** + +Add to `AgentInfo` properties in `api.yaml`: + +```yaml +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 + 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** + +```bash +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`. + +Check the generated field names in `agent.gen.go` and match them exactly. + +**Step 4: Run tests** + +```bash +go test -v ./internal/api/agent/... +go build ./... +``` + +**Step 5: Commit** + +``` +feat(api): expose agent facts in AgentInfo responses +``` + +--- + +### Task 9: Default Config + +**Files:** + +- Modify: `osapi.yaml` + +**Step 1: Add defaults** + +Add `nats.facts` section after `nats.registry`: + +```yaml +facts: + bucket: 'agent-facts' + ttl: '5m' + storage: 'file' + replicas: 1 +``` + +Add `agent.facts` section after `agent.labels`: + +```yaml +facts: + interval: '60s' +``` + +**Step 2: Verify config loads** + +```bash +go build ./... +``` + +**Step 3: Commit** + +``` +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` | + +**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 +``` + +--- + +### 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. +- 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 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. + +**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 | + +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** + +``` +docs: update CLI docs with agent facts output +``` + +--- + +### Task 13: 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 +``` + +**Step 5: Docs format** + +```bash +just docs::fmt-check +``` + +All must pass. Fix any issues found. + +--- + +## Out of Scope (Phase 2+) + +- 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) diff --git a/go.mod b/go.mod index 41739c5f..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 @@ -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 diff --git a/go.sum b/go.sum index 014b4a1c..f8ce5a76 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,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-20260301225211-54433f58dfd8 h1:XwUb5ZywbIX4RDBfzxGjJNCCRLxLWD3AGYu4Xn1t104= -github.com/osapi-io/osapi-sdk v0.0.0-20260301225211-54433f58dfd8/go.mod h1:bk9s5LKXaf4c2I1FAf7T7xDjn2ZqZ56Mtjoo7Ce8e6Q= +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= 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..864a2507 --- /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 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/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{}, 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: 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/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/cli/ui.go b/internal/cli/ui.go index bb182512..972defc1 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,13 @@ 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 +598,13 @@ 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..840d251e 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,45 @@ 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 +611,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 +860,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) 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. 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 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..6c07b949 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,145 @@ 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) + }, + }, + { + 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 { @@ -1268,6 +1409,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 +1441,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 +1508,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 +1618,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) diff --git a/internal/job/types.go b/internal/job/types.go index 48be2ce2..77c87403 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -249,6 +249,27 @@ 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"` + 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. +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 +312,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..cf46c2f0 --- /dev/null +++ b/internal/job/types_public_test.go @@ -0,0 +1,304 @@ +// 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", + 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) + }, + }, + { + 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.IPv6) + suite.Empty(result.MAC) + suite.Empty(result.Family) + }, + }, + { + 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", + IPv6: "fe80::1", + MAC: "aa:bb:cc:dd:ee:ff", + Family: "dual", + }, + }, + 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("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"]) + }, + }, + { + 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)) +} 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..065c37ae --- /dev/null +++ b/internal/provider/network/netinfo/mocks/mocks.go @@ -0,0 +1,49 @@ +// 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", + IPv6: "fe80::1", + MAC: "00:11:22:33:44:55", + Family: "dual", + }, + }, 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..cd822170 --- /dev/null +++ b/internal/provider/network/netinfo/netinfo.go @@ -0,0 +1,95 @@ +// 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 provides network interface information. +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) + 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() + }, + } +} + +// 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 := n.AddrsFn(iface) + if err == nil { + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + + 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() + } + } + } + + 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) + } + + 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..6fed3644 --- /dev/null +++ b/internal/provider/network/netinfo/netinfo_public_test.go @@ -0,0 +1,316 @@ +// 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) + addrsFn func(iface net.Interface) ([]net.Addr, 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 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) { + 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() + } + + if tc.addrsFn != nil { + n.AddrsFn = tc.addrsFn + } + + 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) +} 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..dc8a77b5 --- /dev/null +++ b/internal/provider/node/host/ubuntu_get_package_manager_public_test.go @@ -0,0 +1,141 @@ +// 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 + validateFunc func(got string) + }{ + { + 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, + }, + { + 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 { + 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) + } + + 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 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..0ee2b73c --- /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(_ string) (os.FileInfo, error) { + return nil, nil + } + }, + want: "systemd", + wantErr: false, + }, + { + name: "when systemd not detected", + setupMock: func(u *host.Ubuntu) { + u.StatFn = func(_ 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)) +}