Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c525d2b
docs: add log viewing provider design spec
retr0h Apr 1, 2026
b52ee0e
docs: add log management implementation plan
retr0h Apr 1, 2026
a1cfd17
feat(log): add provider interface, platform stubs, and debian impleme…
retr0h Apr 1, 2026
6b2a87c
fix(log): add debug log calls to provider methods
retr0h Apr 1, 2026
ec9301c
feat(log): add operations, permissions, and agent wiring
retr0h Apr 1, 2026
9ada19b
feat(log): add OpenAPI spec and generated code
retr0h Apr 1, 2026
78822bf
feat(log): add API handlers with broadcast support
retr0h Apr 1, 2026
c8bd645
feat(log): add SDK service with tests
retr0h Apr 1, 2026
79188ef
feat(log): add CLI commands for journal log viewing
retr0h Apr 1, 2026
b104ff0
docs: add log management feature docs, SDK example, and cross-references
retr0h Apr 1, 2026
03889af
test(log): add integration test
retr0h Apr 1, 2026
24a9daf
chore(log): fix formatting and lint issues
retr0h Apr 1, 2026
7686fbd
chore: fix docs formatting
retr0h Apr 1, 2026
045897d
fix(log): pass opts struct directly to job client to avoid double-enc…
retr0h Apr 1, 2026
5587e86
feat(log): add list sources operation and fix coverage to 100%
retr0h Apr 1, 2026
69b5a14
test: fix coverage gaps in processor_log and package handlers
retr0h Apr 1, 2026
05e1e15
refactor(log): move unmarshal error case into TestProcessLogQuery table
retr0h Apr 1, 2026
67d697e
docs: add generated API docs for log source endpoint
retr0h Apr 1, 2026
3a275cd
docs: add missing sources operation to docs, SDK example, and cross-r…
retr0h Apr 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions cmd/agent_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import (
"github.com/retr0h/osapi/internal/provider/node/disk"
nodeHost "github.com/retr0h/osapi/internal/provider/node/host"
"github.com/retr0h/osapi/internal/provider/node/load"
logProv "github.com/retr0h/osapi/internal/provider/node/log"
"github.com/retr0h/osapi/internal/provider/node/mem"
ntpProv "github.com/retr0h/osapi/internal/provider/node/ntp"
powerProv "github.com/retr0h/osapi/internal/provider/node/power"
Expand Down Expand Up @@ -205,6 +206,9 @@ func setupAgent(
// --- Package provider ---
packageProvider := createPackageProvider(log, execManager)

// --- Log provider ---
logProvider := createLogProvider(log, execManager)

// --- Build registry ---
registry := agent.NewProviderRegistry()

Expand All @@ -222,6 +226,7 @@ func setupAgent(
processProvider,
userProvider,
packageProvider,
logProvider,
appConfig,
log,
),
Expand All @@ -236,6 +241,7 @@ func setupAgent(
processProvider,
userProvider,
packageProvider,
logProvider,
)

registry.Register("network",
Expand Down Expand Up @@ -536,3 +542,26 @@ func createPackageProvider(
return aptProv.NewLinuxProvider()
}
}

// createLogProvider creates a platform-specific log provider. On Debian, the
// log provider queries journal logs via journalctl. In containers, journalctl
// requires systemd which is not available, so the provider is disabled. On
// other platforms, all operations return ErrUnsupported.
func createLogProvider(
log *slog.Logger,
execManager exec.Manager,
) logProv.Provider {
plat := platform.Detect()

switch plat {
case "debian":
if platform.IsContainer() {
return logProv.NewLinuxProvider()
}
return logProv.NewDebianProvider(log, execManager)
case "darwin":
return logProv.NewDarwinProvider()
default:
return logProv.NewLinuxProvider()
}
}
35 changes: 35 additions & 0 deletions cmd/client_node_log.go
Original file line number Diff line number Diff line change
@@ -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 cmd

import (
"github.com/spf13/cobra"
)

// clientNodeLogCmd represents the clientNodeLog command.
var clientNodeLogCmd = &cobra.Command{
Use: "log",
Short: "View journal logs",
}

func init() {
clientNodeCmd.AddCommand(clientNodeLogCmd)
}
125 changes: 125 additions & 0 deletions cmd/client_node_log_query.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// 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 cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/retr0h/osapi/internal/cli"
"github.com/retr0h/osapi/pkg/sdk/client"
)

// clientNodeLogQueryCmd represents the log query command.
var clientNodeLogQueryCmd = &cobra.Command{
Use: "query",
Short: "Query journal entries",
Long: `Query journal log entries on the target node.`,
Run: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()
host, _ := cmd.Flags().GetString("target")

opts := client.LogQueryOpts{}

if cmd.Flags().Changed("lines") {
lines, _ := cmd.Flags().GetInt("lines")
opts.Lines = &lines
}

since, _ := cmd.Flags().GetString("since")
if since != "" {
opts.Since = &since
}

priority, _ := cmd.Flags().GetString("priority")
if priority != "" {
opts.Priority = &priority
}

resp, err := sdkClient.Log.Query(ctx, host, opts)
if err != nil {
cli.HandleError(err, logger)
return
}

if jsonOutput {
fmt.Println(string(resp.RawJSON()))
return
}

if resp.Data.JobID != "" {
fmt.Println()
cli.PrintKV("Job ID", resp.Data.JobID)
fmt.Println()
}

results := make([]cli.ResultRow, 0)
for _, r := range resp.Data.Results {
if r.Error != "" {
var errPtr *string
e := r.Error
errPtr = &e
results = append(results, cli.ResultRow{
Hostname: r.Hostname,
Status: r.Status,
Error: errPtr,
})

continue
}

for _, e := range r.Entries {
message := e.Message
if len(message) > 80 {
message = message[:77] + "..."
}

results = append(results, cli.ResultRow{
Hostname: r.Hostname,
Status: r.Status,
Fields: []string{
e.Timestamp,
e.Priority,
e.Unit,
message,
},
})
}
}
headers, rows := cli.BuildBroadcastTable(
results,
[]string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"},
)
cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
},
}

func init() {
clientNodeLogCmd.AddCommand(clientNodeLogQueryCmd)

clientNodeLogQueryCmd.PersistentFlags().
Int("lines", 100, "Maximum number of log lines to return")
clientNodeLogQueryCmd.PersistentFlags().
String("since", "", "Return entries since this time (e.g., '1h', '2026-01-01 00:00:00')")
clientNodeLogQueryCmd.PersistentFlags().
String("priority", "", "Filter by priority level (e.g., 'err', 'warning', 'info')")
}
89 changes: 89 additions & 0 deletions cmd/client_node_log_source.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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 cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/retr0h/osapi/internal/cli"
)

// clientNodeLogSourceCmd represents the log source command.
var clientNodeLogSourceCmd = &cobra.Command{
Use: "source",
Short: "List available log sources",
Long: `List unique syslog identifiers available in the journal on the target node.`,
Run: func(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()
host, _ := cmd.Flags().GetString("target")

resp, err := sdkClient.Log.Sources(ctx, host)
if err != nil {
cli.HandleError(err, logger)
return
}

if jsonOutput {
fmt.Println(string(resp.RawJSON()))
return
}

if resp.Data.JobID != "" {
fmt.Println()
cli.PrintKV("Job ID", resp.Data.JobID)
fmt.Println()
}

results := make([]cli.ResultRow, 0)
for _, r := range resp.Data.Results {
if r.Error != "" {
e := r.Error
results = append(results, cli.ResultRow{
Hostname: r.Hostname,
Status: r.Status,
Error: &e,
})

continue
}

for _, source := range r.Sources {
results = append(results, cli.ResultRow{
Hostname: r.Hostname,
Status: r.Status,
Fields: []string{source},
})
}
}

headers, rows := cli.BuildBroadcastTable(
results,
[]string{"SOURCE"},
)
cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}})
},
}

func init() {
clientNodeLogCmd.AddCommand(clientNodeLogSourceCmd)
}
Loading
Loading