From c525d2b18fe3bd2a7ab38f28943ee94a9f827e64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 17:55:11 -0700 Subject: [PATCH 01/19] docs: add log viewing provider design spec Co-Authored-By: Claude --- .../backlog/2026-02-15-feat-log-management.md | 40 ------ ...26-03-31-log-management-provider-design.md | 124 ++++++++++++++++++ 2 files changed, 124 insertions(+), 40 deletions(-) delete mode 100644 docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md create mode 100644 docs/plans/2026-03-31-log-management-provider-design.md diff --git a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md b/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md deleted file mode 100644 index f00b53ef2..000000000 --- a/docs/docs/sidebar/development/tasks/backlog/2026-02-15-feat-log-management.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -title: Log viewing and management -status: backlog -created: 2026-02-15 -updated: 2026-02-15 ---- - -## Objective - -Add log viewing endpoints. Appliance operators need to inspect system and -service logs for troubleshooting without SSH access. - -## API Endpoints - -``` -GET /log/journal - Query systemd journal entries -GET /log/journal/unit/{name} - Get logs for specific unit -GET /log/syslog - Get recent syslog entries -``` - -## Operations - -- `log.journal.get` (query) -- `log.journal.unit.get` (query) -- `log.syslog.get` (query) - -## Provider - -- `internal/provider/node/log/` -- Use `journalctl` with JSON output for structured log parsing -- Support query params: since, until, unit, priority, limit, grep -- Return type: `LogEntry` with timestamp, unit, priority, message, PID, hostname - -## Notes - -- Logs can be very large — pagination and limits are essential -- Support streaming in future (SSE or WebSocket) for tail -f equivalent -- Read-only — no log deletion via API -- Scopes: `log:read` -- Consider security: some logs may contain sensitive info diff --git a/docs/plans/2026-03-31-log-management-provider-design.md b/docs/plans/2026-03-31-log-management-provider-design.md new file mode 100644 index 000000000..9bca96444 --- /dev/null +++ b/docs/plans/2026-03-31-log-management-provider-design.md @@ -0,0 +1,124 @@ +# Log Viewing Provider Design + +## Overview + +Add log viewing to OSAPI. Query systemd journal entries with +optional filtering by lines, time range, and priority. Read-only +— no write operations. Uses `journalctl --output=json` for +structured parsing. + +## Architecture + +Direct provider at `internal/provider/node/log/`. + +- **Category**: `node` +- **Path prefix**: `/node/{hostname}/log` +- **Permissions**: `log:read` +- **Provider type**: direct (exec.Manager) + +## Provider Interface + +```go +type Provider interface { + Query(ctx context.Context, opts QueryOpts) ([]Entry, error) + QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error) +} +``` + +## Data Types + +```go +type QueryOpts struct { + Lines int `json:"lines,omitempty"` + Since string `json:"since,omitempty"` + Priority string `json:"priority,omitempty"` +} + +type Entry struct { + Timestamp string `json:"timestamp"` + Unit string `json:"unit,omitempty"` + Priority string `json:"priority"` + Message string `json:"message"` + PID int `json:"pid,omitempty"` + Hostname string `json:"hostname,omitempty"` +} +``` + +## Debian Implementation + +- **Query**: run `journalctl --output=json -n ` with + optional `--since=` and `--priority=`. Parse + JSON lines output — each line is a JSON object with fields + `__REALTIME_TIMESTAMP`, `SYSLOG_IDENTIFIER`, `PRIORITY`, + `MESSAGE`, `_PID`, `_HOSTNAME`. +- **QueryUnit**: same but with `-u ` flag. + +Default `lines` is 100 if not specified. `since` uses journalctl +format (e.g., `"1 hour ago"`, `"2026-03-31"`). `priority` uses +journalctl levels (0-7 or names like `err`, `warning`). + +## Platform Implementations + +| Platform | Implementation | +| -------- | -------------------------- | +| Debian | journalctl --output=json | +| Darwin | ErrUnsupported | +| Linux | ErrUnsupported | + +## Container Behavior + +Return `ErrUnsupported` in containers — `journalctl` requires +systemd which isn't available in containers. + +## API Endpoints + +| Method | Path | Permission | Description | +| ------ | --------------------------------- | ---------- | --------------------------- | +| `GET` | `/node/{hostname}/log` | `log:read` | Query journal entries | +| `GET` | `/node/{hostname}/log/unit/{name}`| `log:read` | Query entries for a unit | + +All endpoints support broadcast targeting. + +### Query Parameters + +| Param | Type | Default | Description | +| ---------- | ------- | ------- | ------------------------------------------ | +| `lines` | integer | 100 | Number of entries to return | +| `since` | string | | Time filter (e.g., "1 hour ago") | +| `priority` | string | | Minimum priority (emerg..debug or 0-7) | + +### Response Shape + +```json +{ + "job_id": "...", + "results": [{ + "hostname": "web-01", + "status": "ok", + "entries": [ + { + "timestamp": "2026-03-31T22:30:45.123Z", + "unit": "nginx.service", + "priority": "info", + "message": "Started nginx", + "pid": 1234, + "hostname": "web-01" + } + ] + }] +} +``` + +## SDK + +```go +client.Log.Query(ctx, host, opts) +client.Log.QueryUnit(ctx, host, unit, opts) +``` + +`LogQueryOpts` struct with optional `Lines`, `Since`, `Priority`. + +## Permissions + +- `log:read` — query journal entries. Added to admin, write, and + read roles. From b52ee0ec9ae180e0f0204285bf5b5e16436578f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 18:21:21 -0700 Subject: [PATCH 02/19] docs: add log management implementation plan Co-Authored-By: Claude --- .../2026-03-31-log-management-provider.md | 2693 +++++++++++++++++ 1 file changed, 2693 insertions(+) create mode 100644 docs/plans/2026-03-31-log-management-provider.md diff --git a/docs/plans/2026-03-31-log-management-provider.md b/docs/plans/2026-03-31-log-management-provider.md new file mode 100644 index 000000000..097415990 --- /dev/null +++ b/docs/plans/2026-03-31-log-management-provider.md @@ -0,0 +1,2693 @@ +# Log Management Provider Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add read-only log viewing to OSAPI via `journalctl --output=json`, with optional filtering by lines, time range, and priority. + +**Architecture:** Direct provider at `internal/provider/node/log/` using `exec.Manager` to run `journalctl`. Two API endpoints: query all journal entries and query by unit name. Read-only with `log:read` permission added to all built-in roles. + +**Tech Stack:** Go, exec.Manager, journalctl JSON output, oapi-codegen strict-server + +--- + +## File Structure + +### Provider Layer +- Create: `internal/provider/node/log/types.go` — Provider interface + domain types +- Create: `internal/provider/node/log/debian.go` — journalctl implementation +- Create: `internal/provider/node/log/debian_query.go` — shared query logic +- Create: `internal/provider/node/log/darwin.go` — macOS stub +- Create: `internal/provider/node/log/linux.go` — generic Linux stub +- Create: `internal/provider/node/log/mocks/generate.go` — mockgen directive +- Test: `internal/provider/node/log/debian_public_test.go` +- Test: `internal/provider/node/log/darwin_public_test.go` +- Test: `internal/provider/node/log/linux_public_test.go` + +### Agent Layer +- Create: `internal/agent/processor_log.go` — log operation dispatcher +- Modify: `internal/agent/processor.go` — add `log` case + logProvider param +- Modify: `cmd/agent_setup.go` — create log provider factory, wire into registry +- Test: `internal/agent/processor_log_public_test.go` + +### API Layer +- Create: `internal/controller/api/node/log/gen/api.yaml` — OpenAPI spec +- Create: `internal/controller/api/node/log/gen/cfg.yaml` — oapi-codegen config +- Create: `internal/controller/api/node/log/gen/generate.go` — go:generate +- Create: `internal/controller/api/node/log/types.go` — handler struct +- Create: `internal/controller/api/node/log/log.go` — New(), compile-time check +- Create: `internal/controller/api/node/log/validate.go` — validateHostname +- Create: `internal/controller/api/node/log/log_query_get.go` — query handler +- Create: `internal/controller/api/node/log/log_unit_get.go` — query unit handler +- Create: `internal/controller/api/node/log/handler.go` — Handler() registration +- Modify: `cmd/controller_setup.go` — register log handler +- Test: `internal/controller/api/node/log/log_query_get_public_test.go` +- Test: `internal/controller/api/node/log/log_unit_get_public_test.go` +- Test: `internal/controller/api/node/log/handler_public_test.go` + +### Operations & Permissions +- Modify: `pkg/sdk/client/operations.go` — add log operation constants +- Modify: `internal/job/types.go` — add log operation aliases +- Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead` +- Modify: `internal/authtoken/permissions.go` — add `PermLogRead` to all roles + +### SDK Layer +- Create: `pkg/sdk/client/log.go` — LogService methods +- Create: `pkg/sdk/client/log_types.go` — SDK result types + conversions +- Modify: `pkg/sdk/client/osapi.go` — add Log field +- Test: `pkg/sdk/client/log_public_test.go` +- Test: `pkg/sdk/client/log_types_public_test.go` + +### CLI Layer +- Create: `cmd/client_node_log.go` — parent command +- Create: `cmd/client_node_log_query.go` — query subcommand +- Create: `cmd/client_node_log_unit.go` — query-unit subcommand + +### Documentation +- Create: `docs/docs/sidebar/features/log-management.md` — feature page +- Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md` — CLI landing +- Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md` — query CLI doc +- Create: `docs/docs/sidebar/usage/cli/client/node/log/unit.md` — unit CLI doc +- Create: `docs/docs/sidebar/sdk/client/operations/log.md` — SDK doc +- Create: `examples/sdk/client/log.go` — SDK example +- Modify: `docs/docs/sidebar/features/features.md` — add log to table +- Modify: `docs/docs/sidebar/features/authentication.md` — add log:read to roles +- Modify: `docs/docs/sidebar/usage/configuration.md` — add log:read to permissions +- Modify: `docs/docs/sidebar/architecture/architecture.md` — add log feature link +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add log endpoints +- Modify: `docs/docusaurus.config.ts` — add SDK dropdown + features dropdown + +### Integration Test +- Create: `test/integration/log_test.go` — smoke test + +--- + +### Task 1: Provider Interface and Types + +**Files:** +- Create: `internal/provider/node/log/types.go` + +- [ ] **Step 1: Create provider interface and types** + +```go +// 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 log provides log viewing operations. +package log + +import ( + "context" +) + +// Provider implements log viewing operations. +type Provider interface { + // Query returns journal entries with optional filtering. + Query(ctx context.Context, opts QueryOpts) ([]Entry, error) + // QueryUnit returns journal entries for a specific systemd unit. + QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error) +} + +// QueryOpts contains optional filters for log queries. +type QueryOpts struct { + Lines int `json:"lines,omitempty"` + Since string `json:"since,omitempty"` + Priority string `json:"priority,omitempty"` +} + +// Entry represents a single journal entry. +type Entry struct { + Timestamp string `json:"timestamp"` + Unit string `json:"unit,omitempty"` + Priority string `json:"priority"` + Message string `json:"message"` + PID int `json:"pid,omitempty"` + Hostname string `json:"hostname,omitempty"` +} +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./internal/provider/node/log/...` +Expected: PASS + +- [ ] **Step 3: Commit** + +```bash +git add internal/provider/node/log/types.go +git commit -m "feat(log): add provider interface and types" +``` + +--- + +### Task 2: Platform Stubs (Darwin + Linux) + +**Files:** +- Create: `internal/provider/node/log/darwin.go` +- Create: `internal/provider/node/log/linux.go` +- Test: `internal/provider/node/log/darwin_public_test.go` +- Test: `internal/provider/node/log/linux_public_test.go` + +- [ ] **Step 1: Write darwin stub tests** + +```go +// 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 log_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + logProv "github.com/retr0h/osapi/internal/provider/node/log" + "github.com/retr0h/osapi/internal/provider" +) + +type DarwinPublicTestSuite struct { + suite.Suite + + provider *logProv.Darwin +} + +func (s *DarwinPublicTestSuite) SetupTest() { + s.provider = logProv.NewDarwinProvider() +} + +func (s *DarwinPublicTestSuite) TestQuery() { + _, err := s.provider.Query(context.Background(), logProv.QueryOpts{}) + + assert.ErrorIs(s.T(), err, provider.ErrUnsupported) +} + +func (s *DarwinPublicTestSuite) TestQueryUnit() { + _, err := s.provider.QueryUnit(context.Background(), "nginx.service", logProv.QueryOpts{}) + + assert.ErrorIs(s.T(), err, provider.ErrUnsupported) +} + +func TestDarwinPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinPublicTestSuite)) +} +``` + +- [ ] **Step 2: Write linux stub tests** + +```go +// 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 log_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + logProv "github.com/retr0h/osapi/internal/provider/node/log" + "github.com/retr0h/osapi/internal/provider" +) + +type LinuxPublicTestSuite struct { + suite.Suite + + provider *logProv.Linux +} + +func (s *LinuxPublicTestSuite) SetupTest() { + s.provider = logProv.NewLinuxProvider() +} + +func (s *LinuxPublicTestSuite) TestQuery() { + _, err := s.provider.Query(context.Background(), logProv.QueryOpts{}) + + assert.ErrorIs(s.T(), err, provider.ErrUnsupported) +} + +func (s *LinuxPublicTestSuite) TestQueryUnit() { + _, err := s.provider.QueryUnit(context.Background(), "nginx.service", logProv.QueryOpts{}) + + assert.ErrorIs(s.T(), err, provider.ErrUnsupported) +} + +func TestLinuxPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxPublicTestSuite)) +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `go test -v ./internal/provider/node/log/...` +Expected: FAIL — Darwin and Linux types don't exist yet + +- [ ] **Step 4: Implement darwin stub** + +```go +// 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 log + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/internal/provider" +) + +// Darwin implements the Provider interface for Darwin (macOS). +// All methods return ErrUnsupported as log viewing is not available on macOS. +type Darwin struct{} + +// NewDarwinProvider factory to create a new Darwin instance. +func NewDarwinProvider() *Darwin { + return &Darwin{} +} + +// Query returns ErrUnsupported on Darwin. +func (d *Darwin) Query( + _ context.Context, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} + +// QueryUnit returns ErrUnsupported on Darwin. +func (d *Darwin) QueryUnit( + _ context.Context, + _ string, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} +``` + +- [ ] **Step 5: Implement linux stub** + +```go +// 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 log + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/internal/provider" +) + +// Linux implements the Provider interface for generic Linux. +// All methods return ErrUnsupported as this is a generic Linux stub. +type Linux struct{} + +// NewLinuxProvider factory to create a new Linux instance. +func NewLinuxProvider() *Linux { + return &Linux{} +} + +// Query returns ErrUnsupported on generic Linux. +func (l *Linux) Query( + _ context.Context, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} + +// QueryUnit returns ErrUnsupported on generic Linux. +func (l *Linux) QueryUnit( + _ context.Context, + _ string, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test -v ./internal/provider/node/log/...` +Expected: PASS — all 4 tests pass + +- [ ] **Step 7: Commit** + +```bash +git add internal/provider/node/log/darwin.go internal/provider/node/log/linux.go \ + internal/provider/node/log/darwin_public_test.go internal/provider/node/log/linux_public_test.go +git commit -m "feat(log): add darwin and linux provider stubs" +``` + +--- + +### Task 3: Debian Provider Implementation + +**Files:** +- Create: `internal/provider/node/log/debian.go` +- Create: `internal/provider/node/log/debian_query.go` +- Create: `internal/provider/node/log/mocks/generate.go` +- Test: `internal/provider/node/log/debian_public_test.go` + +- [ ] **Step 1: Create mock generator** + +```go +// 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=provider.gen.go -package=mocks +``` + +- [ ] **Step 2: Generate mocks** + +Run: `go generate ./internal/provider/node/log/mocks/...` +Expected: generates `provider.gen.go` + +- [ ] **Step 3: Write debian provider tests** + +```go +// 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 log_test + +import ( + "context" + "fmt" + "log/slog" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/exec" + execMocks "github.com/retr0h/osapi/internal/exec/mocks" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +type DebianPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockExecManager *execMocks.MockManager + provider *logProv.Debian + ctx context.Context + logger *slog.Logger +} + +func (s *DebianPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockExecManager = execMocks.NewMockManager(s.mockCtrl) + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) + s.provider = logProv.NewDebianProvider(s.logger, s.mockExecManager) + s.ctx = context.Background() +} + +func (s *DebianPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *DebianPublicTestSuite) TestQuery() { + journalLine1 := `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}` + journalLine2 := `{"__REALTIME_TIMESTAMP":"1711929046000000","SYSLOG_IDENTIFIER":"sshd","PRIORITY":"4","MESSAGE":"Connection closed","_PID":"5678","_HOSTNAME":"web-01"}` + journalOutput := journalLine1 + "\n" + journalLine2 + "\n" + + tests := []struct { + name string + opts logProv.QueryOpts + setupMock func() + validateFunc func(entries []logProv.Entry, err error) + }{ + { + name: "default query with no options", + opts: logProv.QueryOpts{}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "100"}). + Return(journalOutput, nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 2) + assert.Equal(s.T(), "nginx", entries[0].Unit) + assert.Equal(s.T(), "info", entries[0].Priority) + assert.Equal(s.T(), "Started nginx", entries[0].Message) + assert.Equal(s.T(), 1234, entries[0].PID) + assert.Equal(s.T(), "web-01", entries[0].Hostname) + assert.Equal(s.T(), "sshd", entries[1].Unit) + assert.Equal(s.T(), "warning", entries[1].Priority) + }, + }, + { + name: "query with all options", + opts: logProv.QueryOpts{ + Lines: 50, + Since: "1 hour ago", + Priority: "err", + }, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{ + "--output=json", + "-n", "50", + "--since=1 hour ago", + "--priority=err", + }). + Return(journalOutput, nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 2) + }, + }, + { + name: "query with custom lines", + opts: logProv.QueryOpts{Lines: 10}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "10"}). + Return(journalOutput, nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 2) + }, + }, + { + name: "exec error", + opts: logProv.QueryOpts{}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "100"}). + Return("", fmt.Errorf("exec failed")) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.Error(s.T(), err) + assert.Nil(s.T(), entries) + assert.Contains(s.T(), err.Error(), "log: query") + }, + }, + { + name: "empty output", + opts: logProv.QueryOpts{}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "100"}). + Return("", nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Empty(s.T(), entries) + }, + }, + { + name: "malformed JSON line skipped", + opts: logProv.QueryOpts{}, + setupMock: func() { + output := "not json\n" + journalLine1 + "\n" + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "100"}). + Return(output, nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 1) + assert.Equal(s.T(), "nginx", entries[0].Unit) + }, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + tc.setupMock() + entries, err := s.provider.Query(s.ctx, tc.opts) + tc.validateFunc(entries, err) + }) + } +} + +func (s *DebianPublicTestSuite) TestQueryUnit() { + journalLine := `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}` + + tests := []struct { + name string + unit string + opts logProv.QueryOpts + setupMock func() + validateFunc func(entries []logProv.Entry, err error) + }{ + { + name: "query unit with defaults", + unit: "nginx.service", + opts: logProv.QueryOpts{}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{ + "--output=json", + "-u", "nginx.service", + "-n", "100", + }). + Return(journalLine+"\n", nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 1) + assert.Equal(s.T(), "nginx", entries[0].Unit) + }, + }, + { + name: "query unit with all options", + unit: "sshd.service", + opts: logProv.QueryOpts{ + Lines: 25, + Since: "2026-03-31", + Priority: "warning", + }, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", []string{ + "--output=json", + "-u", "sshd.service", + "-n", "25", + "--since=2026-03-31", + "--priority=warning", + }). + Return(journalLine+"\n", nil) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.NoError(s.T(), err) + assert.Len(s.T(), entries, 1) + }, + }, + { + name: "exec error", + unit: "nginx.service", + opts: logProv.QueryOpts{}, + setupMock: func() { + s.mockExecManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return("", fmt.Errorf("exec failed")) + }, + validateFunc: func(entries []logProv.Entry, err error) { + assert.Error(s.T(), err) + assert.Nil(s.T(), entries) + assert.Contains(s.T(), err.Error(), "log: query unit") + }, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + tc.setupMock() + entries, err := s.provider.QueryUnit(s.ctx, tc.unit, tc.opts) + tc.validateFunc(entries, err) + }) + } +} + +func TestDebianPublicTestSuite(t *testing.T) { + suite.Run(t, new(DebianPublicTestSuite)) +} +``` + +- [ ] **Step 4: Run tests to verify they fail** + +Run: `go test -v ./internal/provider/node/log/...` +Expected: FAIL — Debian type doesn't exist yet + +- [ ] **Step 5: Implement debian.go** + +```go +// 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 log + +import ( + "context" + "fmt" + "log/slog" + + "github.com/retr0h/osapi/internal/exec" + "github.com/retr0h/osapi/internal/provider" +) + +// Compile-time checks. +var ( + _ Provider = (*Debian)(nil) + _ provider.FactsSetter = (*Debian)(nil) +) + +// Debian implements the Provider interface for Debian-family systems. +type Debian struct { + provider.FactsAware + logger *slog.Logger + execManager exec.Manager +} + +// NewDebianProvider factory to create a new Debian instance. +func NewDebianProvider( + logger *slog.Logger, + execManager exec.Manager, +) *Debian { + return &Debian{ + logger: logger.With(slog.String("subsystem", "provider.log")), + execManager: execManager, + } +} + +// Query returns journal entries with optional filtering. +func (d *Debian) Query( + _ context.Context, + opts QueryOpts, +) ([]Entry, error) { + d.logger.Debug("executing log.Query") + + args := buildArgs(opts) + + output, err := d.execManager.RunCmd("journalctl", args) + if err != nil { + return nil, fmt.Errorf("log: query: %w", err) + } + + return parseJournalOutput(output, d.logger), nil +} + +// QueryUnit returns journal entries for a specific systemd unit. +func (d *Debian) QueryUnit( + _ context.Context, + unit string, + opts QueryOpts, +) ([]Entry, error) { + d.logger.Debug("executing log.QueryUnit", + slog.String("unit", unit), + ) + + args := buildUnitArgs(unit, opts) + + output, err := d.execManager.RunCmd("journalctl", args) + if err != nil { + return nil, fmt.Errorf("log: query unit: %w", err) + } + + return parseJournalOutput(output, d.logger), nil +} +``` + +- [ ] **Step 6: Implement debian_query.go** + +```go +// 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 log + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" + "time" +) + +// journalEntry represents the raw JSON output from journalctl --output=json. +type journalEntry struct { + RealtimeTimestamp string `json:"__REALTIME_TIMESTAMP"` + SyslogIdentifier string `json:"SYSLOG_IDENTIFIER"` + Priority string `json:"PRIORITY"` + Message string `json:"MESSAGE"` + PID string `json:"_PID"` + Hostname string `json:"_HOSTNAME"` +} + +// priorityNames maps journalctl numeric priorities to human-readable names. +var priorityNames = map[string]string{ + "0": "emerg", + "1": "alert", + "2": "crit", + "3": "err", + "4": "warning", + "5": "notice", + "6": "info", + "7": "debug", +} + +// buildArgs constructs the journalctl command arguments for a general query. +func buildArgs( + opts QueryOpts, +) []string { + args := []string{"--output=json"} + + lines := opts.Lines + if lines <= 0 { + lines = 100 + } + args = append(args, "-n", fmt.Sprintf("%d", lines)) + + if opts.Since != "" { + args = append(args, "--since="+opts.Since) + } + + if opts.Priority != "" { + args = append(args, "--priority="+opts.Priority) + } + + return args +} + +// buildUnitArgs constructs the journalctl command arguments for a unit query. +func buildUnitArgs( + unit string, + opts QueryOpts, +) []string { + args := []string{"--output=json", "-u", unit} + + lines := opts.Lines + if lines <= 0 { + lines = 100 + } + args = append(args, "-n", fmt.Sprintf("%d", lines)) + + if opts.Since != "" { + args = append(args, "--since="+opts.Since) + } + + if opts.Priority != "" { + args = append(args, "--priority="+opts.Priority) + } + + return args +} + +// parseJournalOutput parses the JSON lines output from journalctl. +func parseJournalOutput( + output string, + logger *slog.Logger, +) []Entry { + lines := strings.Split(strings.TrimSpace(output), "\n") + var entries []Entry + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var je journalEntry + if err := json.Unmarshal([]byte(line), &je); err != nil { + logger.Debug("skipping malformed journal line", + slog.String("error", err.Error()), + ) + continue + } + + entries = append(entries, journalEntryToEntry(je)) + } + + return entries +} + +// journalEntryToEntry converts a raw journal entry to the provider Entry type. +func journalEntryToEntry( + je journalEntry, +) Entry { + ts := parseTimestamp(je.RealtimeTimestamp) + priority := priorityNames[je.Priority] + if priority == "" { + priority = je.Priority + } + + var pid int + if je.PID != "" { + pid, _ = strconv.Atoi(je.PID) + } + + return Entry{ + Timestamp: ts, + Unit: je.SyslogIdentifier, + Priority: priority, + Message: je.Message, + PID: pid, + Hostname: je.Hostname, + } +} + +// parseTimestamp converts a journalctl __REALTIME_TIMESTAMP (microseconds +// since epoch) to RFC3339 format. +func parseTimestamp( + usec string, +) string { + us, err := strconv.ParseInt(usec, 10, 64) + if err != nil { + return usec + } + + t := time.UnixMicro(us).UTC() + + return t.Format(time.RFC3339Nano) +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `go test -v ./internal/provider/node/log/...` +Expected: PASS — all tests pass + +- [ ] **Step 8: Commit** + +```bash +git add internal/provider/node/log/debian.go internal/provider/node/log/debian_query.go \ + internal/provider/node/log/debian_public_test.go \ + internal/provider/node/log/mocks/ +git commit -m "feat(log): add debian provider with journalctl parsing" +``` + +--- + +### Task 4: Operations, Permissions, and Agent Wiring + +**Files:** +- Modify: `pkg/sdk/client/operations.go` — add log operations +- Modify: `internal/job/types.go` — add log operation aliases +- Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead` +- Modify: `internal/authtoken/permissions.go` — add `PermLogRead` to all roles +- Create: `internal/agent/processor_log.go` — log dispatcher +- Modify: `internal/agent/processor.go` — add `log` case + logProvider param +- Modify: `cmd/agent_setup.go` — create log provider, wire into registry +- Test: `internal/agent/processor_log_public_test.go` + +- [ ] **Step 1: Add operation constants to SDK** + +Add to `pkg/sdk/client/operations.go` after the Package operations block: + +```go +// Log operations. +const ( + OpLogQuery JobOperation = "node.log.query" + OpLogQueryUnit JobOperation = "node.log.queryUnit" +) +``` + +- [ ] **Step 2: Add operation aliases to job types** + +Add to `internal/job/types.go` after the Package operations block: + +```go +// Log operations. +const ( + OperationLogQuery = client.OpLogQuery + OperationLogQueryUnit = client.OpLogQueryUnit +) +``` + +- [ ] **Step 3: Add permission constant to SDK** + +Add to `pkg/sdk/client/permissions.go` after the Package permissions: + +```go + PermLogRead Permission = "log:read" +``` + +- [ ] **Step 4: Add permission to authtoken** + +Add to `internal/authtoken/permissions.go`: + +1. Add constant after PackageWrite: +```go + PermLogRead = client.PermLogRead +``` + +2. Add to `AllPermissions` slice: +```go + PermLogRead, +``` + +3. Add to admin role after `PermPackageWrite`: +```go + PermLogRead, +``` + +4. Add to write role after `PermPackageWrite`: +```go + PermLogRead, +``` + +5. Add to read role after `PermPackageRead`: +```go + PermLogRead, +``` + +- [ ] **Step 5: Write agent processor tests** + +Create `internal/agent/processor_log_public_test.go`. The tests exercise `processLogOperation` via the node processor's `log` case. Follow the same pattern as `processor_process_public_test.go` — table-driven tests with gomock for the log provider mock. Test cases: +- `log.query` with default opts (empty data) +- `log.query` with all options (lines, since, priority) +- `log.queryUnit` with unit name +- unsupported sub-operation (`log.invalid`) +- invalid operation format (`log` with no sub-op) +- nil log provider + +The tests construct a `job.Request` with `Operation: "log.query"` (note: the node processor strips the base operation from the dotted format `"hostname.get"` → `"hostname"`, but for log the operation string passed to `processLogOperation` is already `"log.query"`, `"log.queryUnit"` etc. The node processor matches on `baseOperation` which is `"log"`, then delegates to `processLogOperation` which splits on `.` to get the sub-op). + +- [ ] **Step 6: Run tests to verify they fail** + +Run: `go test -run TestProcessorLogPublicTestSuite -v ./internal/agent/...` +Expected: FAIL — `processLogOperation` doesn't exist + +- [ ] **Step 7: Implement processor_log.go** + +Create `internal/agent/processor_log.go`: + +```go +// 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" + "fmt" + "log/slog" + "strings" + + "github.com/retr0h/osapi/internal/job" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +// processLogOperation dispatches log sub-operations. +func processLogOperation( + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if logProvider == nil { + return nil, fmt.Errorf("log provider not available") + } + + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid log operation: %s", jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "query": + return processLogQuery(ctx, logProvider, logger, jobRequest) + case "queryUnit": + return processLogQueryUnit(ctx, logProvider, logger, jobRequest) + default: + return nil, fmt.Errorf("unsupported log operation: %s", jobRequest.Operation) + } +} + +// processLogQuery handles the log.query operation. +func processLogQuery( + ctx context.Context, + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + logger.Debug("executing log.Query") + + var opts logProv.QueryOpts + if jobRequest.Data != nil { + _ = json.Unmarshal(jobRequest.Data, &opts) + } + + result, err := logProvider.Query(ctx, opts) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} + +// processLogQueryUnit handles the log.queryUnit operation. +func processLogQueryUnit( + ctx context.Context, + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + logger.Debug("executing log.QueryUnit") + + var data struct { + Unit string `json:"unit"` + logProv.QueryOpts + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal log query unit data: %w", err) + } + + result, err := logProvider.QueryUnit(ctx, data.Unit, data.QueryOpts) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} +``` + +- [ ] **Step 8: Wire log into NewNodeProcessor** + +Add `logProvider logProv.Provider` parameter to `NewNodeProcessor` in `internal/agent/processor.go`. Add the import: + +```go +logProv "github.com/retr0h/osapi/internal/provider/node/log" +``` + +Add the case in the switch: + +```go + case "log": + return processLogOperation(logProvider, logger, req) +``` + +- [ ] **Step 9: Create log provider factory in agent_setup.go** + +Add import: +```go +logProv "github.com/retr0h/osapi/internal/provider/node/log" +``` + +Add factory function: +```go +// createLogProvider creates a platform-specific log provider. On Debian, the +// log provider reads journal entries via journalctl. In containers, journalctl +// is not available — returns ErrUnsupported. 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() { + log.Info("running in container, log operations disabled") + return logProv.NewLinuxProvider() + } + return logProv.NewDebianProvider(log, execManager) + case "darwin": + return logProv.NewDarwinProvider() + default: + return logProv.NewLinuxProvider() + } +} +``` + +Add to `setupAgent` after `packageProvider`: +```go + // --- Log provider --- + logProvider := createLogProvider(log, execManager) +``` + +Add `logProvider` to the `NewNodeProcessor` call and the providers list in `registry.Register("node", ...)`. + +- [ ] **Step 10: Run tests** + +Run: `go test -v ./internal/agent/... && go build ./...` +Expected: PASS + +- [ ] **Step 11: Commit** + +```bash +git add pkg/sdk/client/operations.go internal/job/types.go \ + pkg/sdk/client/permissions.go internal/authtoken/permissions.go \ + internal/agent/processor_log.go internal/agent/processor_log_public_test.go \ + internal/agent/processor.go cmd/agent_setup.go +git commit -m "feat(log): add operations, permissions, and agent wiring" +``` + +--- + +### Task 5: OpenAPI Spec and Code Generation + +**Files:** +- Create: `internal/controller/api/node/log/gen/api.yaml` +- Create: `internal/controller/api/node/log/gen/cfg.yaml` +- Create: `internal/controller/api/node/log/gen/generate.go` + +- [ ] **Step 1: Create OpenAPI spec** + +Create `internal/controller/api/node/log/gen/api.yaml`: + +```yaml +# 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. + +--- +openapi: 3.0.0 +info: + title: Log Management API + version: 1.0.0 +tags: + - name: log_operations + x-displayName: Node/Log + description: Log viewing operations on a target node. + +paths: + /node/{hostname}/log: + get: + summary: Query journal entries + description: > + Query systemd journal entries on the target node with optional + filtering by lines, time range, and priority. + tags: + - log_operations + operationId: GetNodeLog + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - name: lines + in: query + required: false + description: Number of entries to return (default 100). + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Time filter in journalctl format (e.g., "1 hour ago", + "2026-03-31"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Minimum priority level (0-7 or name: emerg, alert, crit, + err, warning, notice, info, debug). + schema: + type: string + responses: + '200': + description: Journal entries. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error querying journal. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + /node/{hostname}/log/unit/{name}: + get: + summary: Query journal entries for a unit + description: > + Query systemd journal entries for a specific unit on the target + node with optional filtering. + tags: + - log_operations + operationId: GetNodeLogUnit + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UnitName' + - name: lines + in: query + required: false + description: Number of entries to return (default 100). + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Time filter in journalctl format (e.g., "1 hour ago", + "2026-03-31"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Minimum priority level (0-7 or name: emerg, alert, crit, + err, warning, notice, info, debug). + schema: + type: string + responses: + '200': + description: Journal entries for the unit. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error querying journal. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + +# -- Reusable components -- + +components: + parameters: + Hostname: + name: hostname + in: path + required: true + description: > + Target agent hostname, reserved routing value (_any, _all), + or label selector (key:value). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in handlers via validateHostname(). + x-oapi-codegen-extra-tags: + validate: required,min=1,valid_target + schema: + type: string + minLength: 1 + + UnitName: + name: name + in: path + required: true + description: > + Systemd unit name (e.g., nginx.service, sshd.service). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in the handler. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ErrorResponse: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + # -- Response schemas -- + + LogEntryInfo: + type: object + description: A single journal entry. + properties: + timestamp: + type: string + description: Entry timestamp in RFC3339 format. + example: "2026-03-31T22:30:45.123456Z" + unit: + type: string + description: Systemd unit or syslog identifier. + example: "nginx.service" + priority: + type: string + description: Priority level name. + example: "info" + message: + type: string + description: Log message. + example: "Started nginx" + pid: + type: integer + description: Process ID that generated the entry. + example: 1234 + hostname: + type: string + description: Hostname where the entry originated. + example: "web-01" + + LogResultEntry: + type: object + description: Log query result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + entries: + type: array + description: Journal entries from this agent. + items: + $ref: '#/components/schemas/LogEntryInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + # -- Collection responses -- + + LogCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/LogResultEntry' + required: + - results +``` + +- [ ] **Step 2: Create oapi-codegen config** + +Create `internal/controller/api/node/log/gen/cfg.yaml`: + +```yaml +# 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: gen +output: log.gen.go +generate: + models: true + echo-server: true + strict-server: true +import-mapping: + ../../../common/gen/api.yaml: github.com/retr0h/osapi/internal/controller/api/common/gen +output-options: + # to make sure that all types are generated + skip-prune: true +``` + +- [ ] **Step 3: Create generate.go** + +Create `internal/controller/api/node/log/gen/generate.go`: + +```go +// 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 gen contains generated code for the log API. +package gen + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml +``` + +- [ ] **Step 4: Generate code** + +Run: `go generate ./internal/controller/api/node/log/gen/...` +Expected: generates `log.gen.go` + +- [ ] **Step 5: Regenerate combined spec** + +Run: `just generate` +Expected: combined spec updated, all code regenerates + +- [ ] **Step 6: Commit** + +```bash +git add internal/controller/api/node/log/gen/ +git commit -m "feat(log): add OpenAPI spec and generated code" +``` + +--- + +### Task 6: API Handler Implementation + +**Files:** +- Create: `internal/controller/api/node/log/types.go` +- Create: `internal/controller/api/node/log/log.go` +- Create: `internal/controller/api/node/log/validate.go` +- Create: `internal/controller/api/node/log/log_query_get.go` +- Create: `internal/controller/api/node/log/log_unit_get.go` +- Create: `internal/controller/api/node/log/handler.go` +- Modify: `cmd/controller_setup.go` +- Test: `internal/controller/api/node/log/log_query_get_public_test.go` +- Test: `internal/controller/api/node/log/log_unit_get_public_test.go` +- Test: `internal/controller/api/node/log/handler_public_test.go` + +- [ ] **Step 1: Create handler types, factory, and validate** + +Create `internal/controller/api/node/log/types.go`: +```go +package log + +import ( + "log/slog" + + "github.com/retr0h/osapi/internal/job/client" +) + +// Log implementation of the Log APIs operations. +type Log struct { + // JobClient provides job-based operations for log management. + JobClient client.JobClient + logger *slog.Logger +} +``` + +Create `internal/controller/api/node/log/log.go`: +```go +package log + +import ( + "log/slog" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job/client" +) + +// ensure that we've conformed to the `StrictServerInterface` with a compile-time check +var _ gen.StrictServerInterface = (*Log)(nil) + +// New factory to create a new instance. +func New( + logger *slog.Logger, + jobClient client.JobClient, +) *Log { + return &Log{ + JobClient: jobClient, + logger: logger.With(slog.String("subsystem", "api.log")), + } +} +``` + +Create `internal/controller/api/node/log/validate.go`: +```go +package log + +import "github.com/retr0h/osapi/internal/validation" + +// validateHostname validates a hostname path parameter using the shared +// validator. Returns the error message and false if invalid. +// +// This exists because oapi-codegen does not generate validate tags on +// path parameters in strict-server mode (upstream limitation). +func validateHostname( + hostname string, +) (string, bool) { + return validation.Var(hostname, "required,min=1,valid_target") +} +``` + +(All files need full license headers — follow existing patterns.) + +- [ ] **Step 2: Write handler tests for GetNodeLog** + +Create `internal/controller/api/node/log/log_query_get_public_test.go` — follow the same pattern as `process_list_get_public_test.go`. Test cases: +- success (single target) +- skipped (single target) +- broadcast success +- broadcast with failed/skipped hosts +- validation error (invalid hostname) +- job client error +- TestGetNodeLogHTTP (raw HTTP through middleware) +- TestGetNodeLogRBACHTTP (auth: 401, 403, 200) + +The test should mock `s.mockJobClient.EXPECT().Query(...)` with category `"node"` and operation `job.OperationLogQuery`. The handler must pass query params (`lines`, `since`, `priority`) as JSON data. + +- [ ] **Step 3: Write handler tests for GetNodeLogUnit** + +Create `internal/controller/api/node/log/log_unit_get_public_test.go` — same pattern. Test cases: +- success (single target) +- skipped (single target) +- broadcast success +- validation error +- job client error +- TestGetNodeLogUnitHTTP +- TestGetNodeLogUnitRBACHTTP + +The handler must pass `unit` (from path param) and query params as JSON data with operation `job.OperationLogQueryUnit`. + +- [ ] **Step 4: Implement GetNodeLog handler** + +Create `internal/controller/api/node/log/log_query_get.go`: + +```go +package log + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +// GetNodeLog queries journal entries on a target node. +func (s *Log) GetNodeLog( + ctx context.Context, + request gen.GetNodeLogRequestObject, +) (gen.GetNodeLogResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + + // Defense in depth: current query fields use omitempty so validation + // always passes, but guards against future field additions. + if errMsg, ok := validation.Struct(request.Params); !ok { + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + s.logger.Debug("log query", + slog.String("target", hostname), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + opts := logProv.QueryOpts{} + if request.Params.Lines != nil { + opts.Lines = *request.Params.Lines + } + if request.Params.Since != nil { + opts.Since = *request.Params.Since + } + if request.Params.Priority != nil { + opts.Priority = *request.Params.Priority + } + + data, _ := json.Marshal(opts) + + if job.IsBroadcastTarget(hostname) { + return s.getNodeLogBroadcast(ctx, hostname, data) + } + + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, data) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.LogResultEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + entries := logEntriesFromResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.LogResultEntryStatusOk, + Entries: &entries, + }, + }, + }, nil +} + +// logEntriesFromResponse extracts LogEntryInfo slice from a job response. +func logEntriesFromResponse( + resp *job.Response, +) []gen.LogEntryInfo { + var provEntries []logProv.Entry + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &provEntries) + } + + result := make([]gen.LogEntryInfo, 0, len(provEntries)) + for _, e := range provEntries { + result = append(result, logEntryToGen(e)) + } + + return result +} + +// logEntryToGen converts a provider Entry to a gen.LogEntryInfo. +func logEntryToGen( + e logProv.Entry, +) gen.LogEntryInfo { + ts := e.Timestamp + unit := e.Unit + priority := e.Priority + message := e.Message + pid := e.PID + hostname := e.Hostname + + return gen.LogEntryInfo{ + Timestamp: &ts, + Unit: stringPtrOrNil(unit), + Priority: &priority, + Message: &message, + Pid: intPtrOrNil(pid), + Hostname: stringPtrOrNil(hostname), + } +} + +func stringPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +func intPtrOrNil(i int) *int { + if i == 0 { + return nil + } + return &i +} + +// getNodeLogBroadcast handles broadcast targets for log query. +func (s *Log) getNodeLogBroadcast( + ctx context.Context, + target string, + data json.RawMessage, +) (gen.GetNodeLogResponseObject, error) { + jobID, responses, err := s.JobClient.QueryBroadcast( + ctx, + target, + "node", + job.OperationLogQuery, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + var items []gen.LogResultEntry + for host, resp := range responses { + item := gen.LogResultEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.LogResultEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.LogResultEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.LogResultEntryStatusOk + entries := logEntriesFromResponse(resp) + item.Entries = &entries + } + items = append(items, item) + } + + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: items, + }, nil +} +``` + +Note: The import for `validation` is `"github.com/retr0h/osapi/internal/validation"`. All files need full license headers. + +- [ ] **Step 5: Implement GetNodeLogUnit handler** + +Create `internal/controller/api/node/log/log_unit_get.go` — same pattern as `log_query_get.go` but adds unit from `request.Name` path param. Passes `{"unit":"...","lines":...,"since":"...","priority":"..."}` as job data. Uses `job.OperationLogQueryUnit`. + +- [ ] **Step 6: Implement handler.go** + +Create `internal/controller/api/node/log/handler.go` — same pattern as `internal/controller/api/node/process/handler.go`: + +```go +package log + +import ( + "log/slog" + + "github.com/labstack/echo/v4" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/controller/api" + gen "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job/client" +) + +// Handler returns Log route registration functions. +func Handler( + logger *slog.Logger, + jobClient client.JobClient, + signingKey string, + customRoles map[string][]string, +) []func(e *echo.Echo) { + var tokenManager api.TokenValidator = authtoken.New(logger) + + logHandler := New(logger, jobClient) + + strictHandler := gen.NewStrictHandler( + logHandler, + []gen.StrictMiddlewareFunc{ + func(handler strictecho.StrictEchoHandlerFunc, _ string) strictecho.StrictEchoHandlerFunc { + return api.ScopeMiddleware( + handler, + tokenManager, + signingKey, + gen.BearerAuthScopes, + customRoles, + ) + }, + }, + ) + + return []func(e *echo.Echo){ + func(e *echo.Echo) { + gen.RegisterHandlers(e, strictHandler) + }, + } +} +``` + +- [ ] **Step 7: Register handler in controller_setup.go** + +Add import: +```go +logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" +``` + +Add after the `packageAPI.Handler(...)` line: +```go + handlers = append(handlers, logAPI.Handler(log, jc, signingKey, customRoles)...) +``` + +- [ ] **Step 8: Write handler_public_test.go** + +Test route registration and middleware execution (same pattern as other handler tests). + +- [ ] **Step 9: Run tests** + +Run: `go test -v ./internal/controller/api/node/log/... && go build ./...` +Expected: PASS + +- [ ] **Step 10: Commit** + +```bash +git add internal/controller/api/node/log/ cmd/controller_setup.go +git commit -m "feat(log): add API handlers with broadcast support" +``` + +--- + +### Task 7: SDK Service + +**Files:** +- Create: `pkg/sdk/client/log.go` +- Create: `pkg/sdk/client/log_types.go` +- Modify: `pkg/sdk/client/osapi.go` +- Test: `pkg/sdk/client/log_public_test.go` +- Test: `pkg/sdk/client/log_types_public_test.go` + +- [ ] **Step 1: Write SDK service tests** + +Create `pkg/sdk/client/log_public_test.go` — test with `httptest.Server`. Test cases for `Query` and `QueryUnit`: +- success (200) +- auth error (401, 403) +- server error (500) +- nil response body +- transport error + +- [ ] **Step 2: Write SDK types tests** + +Create `pkg/sdk/client/log_types_public_test.go` — test conversion functions: +- `logCollectionFromGen` with full data +- `logCollectionFromGen` with error entries +- `logEntryInfoFromGen` field mapping +- Nil/empty fields + +- [ ] **Step 3: Implement log_types.go** + +```go +package client + +import ( + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +// LogEntryResult represents the result of a log query for one host. +type LogEntryResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Entries []LogEntry `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +// LogEntry represents a single journal entry. +type LogEntry struct { + Timestamp string `json:"timestamp,omitempty"` + Unit string `json:"unit,omitempty"` + Priority string `json:"priority,omitempty"` + Message string `json:"message,omitempty"` + PID int `json:"pid,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +// LogQueryOpts contains options for log query operations. +type LogQueryOpts struct { + Lines *int + Since *string + Priority *string +} + +// logCollectionFromGen converts a gen.LogCollectionResponse +// to a Collection[LogEntryResult]. +func logCollectionFromGen( + g *gen.LogCollectionResponse, +) Collection[LogEntryResult] { + results := make([]LogEntryResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, logEntryResultFromGen(r)) + } + + return Collection[LogEntryResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// logEntryResultFromGen converts a gen.LogResultEntry to a LogEntryResult. +func logEntryResultFromGen( + r gen.LogResultEntry, +) LogEntryResult { + result := LogEntryResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + } + + if r.Entries != nil { + entries := make([]LogEntry, 0, len(*r.Entries)) + for _, e := range *r.Entries { + entries = append(entries, logEntryInfoFromGen(e)) + } + result.Entries = entries + } + + return result +} + +// logEntryInfoFromGen converts a gen.LogEntryInfo to a LogEntry. +func logEntryInfoFromGen( + e gen.LogEntryInfo, +) LogEntry { + return LogEntry{ + Timestamp: derefString(e.Timestamp), + Unit: derefString(e.Unit), + Priority: derefString(e.Priority), + Message: derefString(e.Message), + PID: derefInt(e.Pid), + Hostname: derefString(e.Hostname), + } +} +``` + +- [ ] **Step 4: Implement log.go** + +```go +package client + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +// LogService provides log viewing operations. +type LogService struct { + client *gen.ClientWithResponses +} + +// Query returns journal entries from the target host. +func (s *LogService) Query( + ctx context.Context, + hostname string, + opts LogQueryOpts, +) (*Response[Collection[LogEntryResult]], error) { + params := &gen.GetNodeLogParams{ + Lines: opts.Lines, + Since: opts.Since, + Priority: opts.Priority, + } + + resp, err := s.client.GetNodeLogWithResponse(ctx, hostname, params) + if err != nil { + return nil, fmt.Errorf("log query: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// QueryUnit returns journal entries for a specific unit on the target host. +func (s *LogService) QueryUnit( + ctx context.Context, + hostname string, + unit string, + opts LogQueryOpts, +) (*Response[Collection[LogEntryResult]], error) { + params := &gen.GetNodeLogUnitParams{ + Lines: opts.Lines, + Since: opts.Since, + Priority: opts.Priority, + } + + resp, err := s.client.GetNodeLogUnitWithResponse(ctx, hostname, unit, params) + if err != nil { + return nil, fmt.Errorf("log query unit: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil +} +``` + +- [ ] **Step 5: Wire LogService in osapi.go** + +Add field to Client struct: +```go + // Log provides log viewing operations (query journal entries). + Log *LogService +``` + +Add initialization in `New()`: +```go + c.Log = &LogService{client: httpClient} +``` + +- [ ] **Step 6: Regenerate SDK client** + +Run: `go generate ./pkg/sdk/client/gen/...` +Expected: SDK client picks up log endpoints + +- [ ] **Step 7: Run tests** + +Run: `go test -v ./pkg/sdk/client/...` +Expected: PASS + +- [ ] **Step 8: Commit** + +```bash +git add pkg/sdk/client/log.go pkg/sdk/client/log_types.go \ + pkg/sdk/client/log_public_test.go pkg/sdk/client/log_types_public_test.go \ + pkg/sdk/client/osapi.go pkg/sdk/client/gen/ +git commit -m "feat(log): add SDK service with tests" +``` + +--- + +### Task 8: CLI Commands + +**Files:** +- Create: `cmd/client_node_log.go` +- Create: `cmd/client_node_log_query.go` +- Create: `cmd/client_node_log_unit.go` + +- [ ] **Step 1: Create parent command** + +Create `cmd/client_node_log.go`: + +```go +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) +} +``` + +- [ ] **Step 2: Create query subcommand** + +Create `cmd/client_node_log_query.go`: + +```go +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 systemd journal entries on the target node.`, + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + lines, _ := cmd.Flags().GetInt("lines") + since, _ := cmd.Flags().GetString("since") + priority, _ := cmd.Flags().GetString("priority") + + opts := client.LogQueryOpts{} + if cmd.Flags().Changed("lines") { + opts.Lines = &lines + } + if since != "" { + opts.Since = &since + } + 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 _, entry := range r.Entries { + message := entry.Message + if len(message) > 80 { + message = message[:77] + "..." + } + + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Fields: []string{ + entry.Timestamp, + entry.Priority, + entry.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, "Number of entries to return") + clientNodeLogQueryCmd.PersistentFlags(). + String("since", "", "Time filter (e.g., \"1 hour ago\")") + clientNodeLogQueryCmd.PersistentFlags(). + String("priority", "", "Minimum priority (emerg..debug or 0-7)") +} +``` + +- [ ] **Step 3: Create query-unit subcommand** + +Create `cmd/client_node_log_unit.go`: + +```go +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/cli" + "github.com/retr0h/osapi/pkg/sdk/client" +) + +// clientNodeLogUnitCmd represents the log unit command. +var clientNodeLogUnitCmd = &cobra.Command{ + Use: "unit", + Short: "Query journal entries for a unit", + Long: `Query systemd journal entries for a specific unit on the target node.`, + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + unit, _ := cmd.Flags().GetString("name") + lines, _ := cmd.Flags().GetInt("lines") + since, _ := cmd.Flags().GetString("since") + priority, _ := cmd.Flags().GetString("priority") + + opts := client.LogQueryOpts{} + if cmd.Flags().Changed("lines") { + opts.Lines = &lines + } + if since != "" { + opts.Since = &since + } + if priority != "" { + opts.Priority = &priority + } + + resp, err := sdkClient.Log.QueryUnit(ctx, host, unit, 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 _, entry := range r.Entries { + message := entry.Message + if len(message) > 80 { + message = message[:77] + "..." + } + + results = append(results, cli.ResultRow{ + Hostname: r.Hostname, + Status: r.Status, + Fields: []string{ + entry.Timestamp, + entry.Priority, + entry.Unit, + message, + }, + }) + } + } + headers, rows := cli.BuildBroadcastTable( + results, + []string{"TIMESTAMP", "PRIORITY", "UNIT", "MESSAGE"}, + ) + cli.PrintCompactTable([]cli.Section{{Headers: headers, Rows: rows}}) + }, +} + +func init() { + clientNodeLogCmd.AddCommand(clientNodeLogUnitCmd) + + clientNodeLogUnitCmd.PersistentFlags(). + String("name", "", "Systemd unit name (required)") + clientNodeLogUnitCmd.PersistentFlags(). + Int("lines", 100, "Number of entries to return") + clientNodeLogUnitCmd.PersistentFlags(). + String("since", "", "Time filter (e.g., \"1 hour ago\")") + clientNodeLogUnitCmd.PersistentFlags(). + String("priority", "", "Minimum priority (emerg..debug or 0-7)") + + _ = clientNodeLogUnitCmd.MarkPersistentFlagRequired("name") +} +``` + +- [ ] **Step 4: Build and verify** + +Run: `go build ./... && go run main.go client node log --help` +Expected: shows `query` and `unit` subcommands + +- [ ] **Step 5: Commit** + +```bash +git add cmd/client_node_log.go cmd/client_node_log_query.go cmd/client_node_log_unit.go +git commit -m "feat(log): add CLI commands for journal log viewing" +``` + +--- + +### Task 9: Documentation and SDK Example + +**Files:** +- Create: `docs/docs/sidebar/features/log-management.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md` +- Create: `docs/docs/sidebar/usage/cli/client/node/log/unit.md` +- Create: `docs/docs/sidebar/sdk/client/operations/log.md` +- Create: `examples/sdk/client/log.go` +- Modify: `docs/docs/sidebar/features/features.md` +- Modify: `docs/docs/sidebar/features/authentication.md` +- Modify: `docs/docs/sidebar/usage/configuration.md` +- Modify: `docs/docs/sidebar/architecture/architecture.md` +- Modify: `docs/docs/sidebar/architecture/api-guidelines.md` +- Modify: `docs/docusaurus.config.ts` + +- [ ] **Step 1: Create feature page** + +Create `docs/docs/sidebar/features/log-management.md` following the process-management.md template. Include: +- How It Works (Query, QueryUnit) +- Operations table +- CLI Usage examples +- Broadcast Support section +- Supported Platforms table (Debian: Full, Darwin: Skipped, Linux: Skipped) +- Container Behavior (skipped — journalctl requires systemd) +- Permissions table (log:read for all operations) +- Related links + +- [ ] **Step 2: Create CLI doc pages** + +Create landing page `docs/docs/sidebar/usage/cli/client/node/log/log.md`: +```markdown +--- +sidebar_position: 1 +--- + +# Log + + +``` + +Create `query.md` and `unit.md` pages with usage examples, flags, and output samples. + +- [ ] **Step 3: Create SDK doc page** + +Create `docs/docs/sidebar/sdk/client/operations/log.md` following existing SDK doc patterns. Document `Query` and `QueryUnit` methods with code examples. + +- [ ] **Step 4: Create SDK example** + +Create `examples/sdk/client/log.go` — demonstrate `Query` and `QueryUnit` with error handling and result printing. Under ~100 lines. + +- [ ] **Step 5: Update cross-references** + +Update these files to add log management: +- `features/features.md` — add row to features table +- `features/authentication.md` — add `log:read` to all three role tables +- `usage/configuration.md` — add `log:read` to permissions comments and role tables +- `architecture/architecture.md` — add log feature link +- `architecture/api-guidelines.md` — add log endpoint rows to path pattern table +- `docusaurus.config.ts` — add to Features dropdown and SDK dropdown + +- [ ] **Step 6: Commit** + +```bash +git add docs/ examples/sdk/client/log.go +git commit -m "docs: add log management feature docs, SDK example, and cross-references" +``` + +--- + +### Task 10: Integration Test + +**Files:** +- Create: `test/integration/log_test.go` + +- [ ] **Step 1: Write integration test** + +Create `test/integration/log_test.go` with `//go:build integration` tag. Follow the pattern of existing integration tests. Test: +- `osapi client node log query --target _any --json` → verify JSON output +- `osapi client node log unit --target _any --name sshd.service --json` → verify JSON output or graceful error + +- [ ] **Step 2: Commit** + +```bash +git add test/integration/log_test.go +git commit -m "test(log): add integration test" +``` + +--- + +### Task 11: Final Verification + +- [ ] **Step 1: Run full test suite** + +```bash +just generate +go build ./... +just go::unit +just go::vet +``` + +Expected: all pass, lint clean + +- [ ] **Step 2: Commit any fixes** + +If `just generate` produces diffs (combined spec, formatting), commit them: + +```bash +git add -A +git commit -m "chore(log): regenerate specs and fix formatting" +``` From a1cfd1704e0f3c64824655ba6b2b4e525a2b2d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 18:38:29 -0700 Subject: [PATCH 03/19] feat(log): add provider interface, platform stubs, and debian implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the log provider package at internal/provider/node/log/ with a Provider interface for querying systemd journal entries, Darwin and Linux stubs returning ErrUnsupported, and a Debian implementation using journalctl --output=json with full JSON parsing and filtering. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- internal/provider/node/log/darwin.go | 54 ++++ .../provider/node/log/darwin_public_test.go | 85 ++++++ internal/provider/node/log/debian.go | 86 ++++++ .../provider/node/log/debian_public_test.go | 264 ++++++++++++++++++ internal/provider/node/log/debian_query.go | 179 ++++++++++++ internal/provider/node/log/linux.go | 54 ++++ .../provider/node/log/linux_public_test.go | 85 ++++++ internal/provider/node/log/mocks/generate.go | 24 ++ .../provider/node/log/mocks/provider.gen.go | 66 +++++ internal/provider/node/log/types.go | 51 ++++ 10 files changed, 948 insertions(+) create mode 100644 internal/provider/node/log/darwin.go create mode 100644 internal/provider/node/log/darwin_public_test.go create mode 100644 internal/provider/node/log/debian.go create mode 100644 internal/provider/node/log/debian_public_test.go create mode 100644 internal/provider/node/log/debian_query.go create mode 100644 internal/provider/node/log/linux.go create mode 100644 internal/provider/node/log/linux_public_test.go create mode 100644 internal/provider/node/log/mocks/generate.go create mode 100644 internal/provider/node/log/mocks/provider.gen.go create mode 100644 internal/provider/node/log/types.go diff --git a/internal/provider/node/log/darwin.go b/internal/provider/node/log/darwin.go new file mode 100644 index 000000000..f5deabc77 --- /dev/null +++ b/internal/provider/node/log/darwin.go @@ -0,0 +1,54 @@ +// 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 log + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/internal/provider" +) + +// Darwin implements the Provider interface for Darwin (macOS). +// All methods return ErrUnsupported as log management is not available on macOS. +type Darwin struct{} + +// NewDarwinProvider factory to create a new Darwin instance. +func NewDarwinProvider() *Darwin { + return &Darwin{} +} + +// Query returns ErrUnsupported on Darwin. +func (d *Darwin) Query( + _ context.Context, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} + +// QueryUnit returns ErrUnsupported on Darwin. +func (d *Darwin) QueryUnit( + _ context.Context, + _ string, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/log/darwin_public_test.go b/internal/provider/node/log/darwin_public_test.go new file mode 100644 index 000000000..6d05c4462 --- /dev/null +++ b/internal/provider/node/log/darwin_public_test.go @@ -0,0 +1,85 @@ +// 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 log_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider" + oslog "github.com/retr0h/osapi/internal/provider/node/log" +) + +type DarwinPublicTestSuite struct { + suite.Suite + + provider *oslog.Darwin +} + +func (suite *DarwinPublicTestSuite) SetupTest() { + suite.provider = oslog.NewDarwinProvider() +} + +func (suite *DarwinPublicTestSuite) TestQuery() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.Query(context.Background(), oslog.QueryOpts{}) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *DarwinPublicTestSuite) TestQueryUnit() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.QueryUnit(context.Background(), "nginx.service", oslog.QueryOpts{}) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +// 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 TestDarwinPublicTestSuite(t *testing.T) { + suite.Run(t, new(DarwinPublicTestSuite)) +} diff --git a/internal/provider/node/log/debian.go b/internal/provider/node/log/debian.go new file mode 100644 index 000000000..15fe4efdd --- /dev/null +++ b/internal/provider/node/log/debian.go @@ -0,0 +1,86 @@ +// 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 log + +import ( + "context" + "fmt" + "log/slog" + + "github.com/retr0h/osapi/internal/exec" + "github.com/retr0h/osapi/internal/provider" +) + +// Compile-time checks. +var ( + _ Provider = (*Debian)(nil) + _ provider.FactsSetter = (*Debian)(nil) +) + +// Debian implements the Provider interface for Debian-family systems +// using journalctl for log querying. +type Debian struct { + provider.FactsAware + logger *slog.Logger + execManager exec.Manager +} + +// NewDebianProvider factory to create a new Debian instance. +func NewDebianProvider( + logger *slog.Logger, + execManager exec.Manager, +) *Debian { + return &Debian{ + logger: logger.With(slog.String("subsystem", "provider.log")), + execManager: execManager, + } +} + +// Query returns journal entries with optional filtering. +func (d *Debian) Query( + _ context.Context, + opts QueryOpts, +) ([]Entry, error) { + args := buildArgs(opts) + + output, err := d.execManager.RunCmd("journalctl", args) + if err != nil { + return nil, fmt.Errorf("log: query: %w", err) + } + + return parseJournalOutput(output, d.logger), nil +} + +// QueryUnit returns journal entries for a specific systemd unit. +func (d *Debian) QueryUnit( + _ context.Context, + unit string, + opts QueryOpts, +) ([]Entry, error) { + args := buildUnitArgs(unit, opts) + + output, err := d.execManager.RunCmd("journalctl", args) + if err != nil { + return nil, fmt.Errorf("log: query unit: %w", err) + } + + return parseJournalOutput(output, d.logger), nil +} diff --git a/internal/provider/node/log/debian_public_test.go b/internal/provider/node/log/debian_public_test.go new file mode 100644 index 000000000..c2e8afde5 --- /dev/null +++ b/internal/provider/node/log/debian_public_test.go @@ -0,0 +1,264 @@ +// 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 log_test + +import ( + "context" + "errors" + "log/slog" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + execmocks "github.com/retr0h/osapi/internal/exec/mocks" + oslog "github.com/retr0h/osapi/internal/provider/node/log" +) + +// singleEntry is a valid journalctl JSON line used across tests. +const singleEntry = `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"}` + +// twoEntries is two valid journalctl JSON lines. +const twoEntries = `{"__REALTIME_TIMESTAMP":"1711929045123456","SYSLOG_IDENTIFIER":"nginx","PRIORITY":"6","MESSAGE":"Started nginx","_PID":"1234","_HOSTNAME":"web-01"} +{"__REALTIME_TIMESTAMP":"1711929046000000","SYSLOG_IDENTIFIER":"sshd","PRIORITY":"5","MESSAGE":"Accepted key","_PID":"5678","_HOSTNAME":"web-01"}` + +type DebianPublicTestSuite struct { + suite.Suite + + ctrl *gomock.Controller + mockManager *execmocks.MockManager + provider *oslog.Debian +} + +func (suite *DebianPublicTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + suite.mockManager = execmocks.NewMockManager(suite.ctrl) + suite.provider = oslog.NewDebianProvider( + slog.New(slog.NewTextHandler(os.Stdout, nil)), + suite.mockManager, + ) +} + +func (suite *DebianPublicTestSuite) TearDownTest() { + suite.ctrl.Finish() +} + +func (suite *DebianPublicTestSuite) TestQuery() { + tests := []struct { + name string + opts oslog.QueryOpts + setupMock func() + wantErr bool + wantErrMsg string + validateFunc func(result []oslog.Entry) + }{ + { + name: "when default query returns entries", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "100"}). + Return(twoEntries, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 2) + suite.Equal("nginx", result[0].Unit) + suite.Equal("info", result[0].Priority) + suite.Equal("Started nginx", result[0].Message) + suite.Equal(1234, result[0].PID) + suite.Equal("web-01", result[0].Hostname) + suite.NotEmpty(result[0].Timestamp) + suite.Equal("sshd", result[1].Unit) + suite.Equal("notice", result[1].Priority) + }, + }, + { + name: "when all options set uses correct args", + opts: oslog.QueryOpts{ + Lines: 50, + Since: "1 hour ago", + Priority: "err", + }, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "--since", "1 hour ago", "--priority", "err", "-n", "50"}). + Return(singleEntry, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + suite.Equal("nginx", result[0].Unit) + }, + }, + { + name: "when custom lines only uses correct args", + opts: oslog.QueryOpts{Lines: 25}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-n", "25"}). + Return(singleEntry, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + }, + }, + { + name: "when exec errors returns error", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return("", errors.New("journalctl not found")) + }, + wantErr: true, + wantErrMsg: "log: query: journalctl not found", + }, + { + name: "when empty output returns empty slice", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return("", nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Empty(result) + }, + }, + { + name: "when malformed JSON line is skipped", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return("not-valid-json\n"+singleEntry, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + suite.Equal("nginx", result[0].Unit) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.setupMock() + + got, err := suite.provider.Query(context.Background(), tc.opts) + + if tc.wantErr { + suite.Error(err) + suite.Contains(err.Error(), tc.wantErrMsg) + suite.Nil(got) + + return + } + + suite.NoError(err) + tc.validateFunc(got) + }) + } +} + +func (suite *DebianPublicTestSuite) TestQueryUnit() { + tests := []struct { + name string + unit string + opts oslog.QueryOpts + setupMock func() + wantErr bool + wantErrMsg string + validateFunc func(result []oslog.Entry) + }{ + { + name: "when default unit query returns entries", + unit: "nginx.service", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-u", "nginx.service", "-n", "100"}). + Return(singleEntry, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + suite.Equal("nginx", result[0].Unit) + suite.Equal("info", result[0].Priority) + suite.Equal("Started nginx", result[0].Message) + suite.Equal(1234, result[0].PID) + suite.Equal("web-01", result[0].Hostname) + }, + }, + { + name: "when all options set uses correct args", + unit: "sshd.service", + opts: oslog.QueryOpts{ + Lines: 20, + Since: "30 minutes ago", + Priority: "warning", + }, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--output=json", "-u", "sshd.service", "--since", "30 minutes ago", "--priority", "warning", "-n", "20"}). + Return(singleEntry, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + }, + }, + { + name: "when exec errors returns error", + unit: "nginx.service", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return("", errors.New("journalctl failed")) + }, + wantErr: true, + wantErrMsg: "log: query unit: journalctl failed", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.setupMock() + + got, err := suite.provider.QueryUnit(context.Background(), tc.unit, tc.opts) + + if tc.wantErr { + suite.Error(err) + suite.Contains(err.Error(), tc.wantErrMsg) + suite.Nil(got) + + return + } + + suite.NoError(err) + 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 TestDebianPublicTestSuite(t *testing.T) { + suite.Run(t, new(DebianPublicTestSuite)) +} diff --git a/internal/provider/node/log/debian_query.go b/internal/provider/node/log/debian_query.go new file mode 100644 index 000000000..a28cfa792 --- /dev/null +++ b/internal/provider/node/log/debian_query.go @@ -0,0 +1,179 @@ +// 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 log + +import ( + "encoding/json" + "log/slog" + "strconv" + "strings" + "time" +) + +// journalEntry represents the raw JSON structure of a journalctl --output=json line. +type journalEntry struct { + Timestamp string `json:"__REALTIME_TIMESTAMP"` + Unit string `json:"SYSLOG_IDENTIFIER"` + Priority string `json:"PRIORITY"` + Message string `json:"MESSAGE"` + PID string `json:"_PID"` + Hostname string `json:"_HOSTNAME"` +} + +// priorityNames maps journald priority numbers to human-readable names. +var priorityNames = map[string]string{ + "0": "emerg", + "1": "alert", + "2": "crit", + "3": "err", + "4": "warning", + "5": "notice", + "6": "info", + "7": "debug", +} + +// buildArgs constructs journalctl arguments for a general query. +// Always includes --output=json. Defaults to 100 lines if opts.Lines <= 0. +func buildArgs( + opts QueryOpts, +) []string { + lines := opts.Lines + if lines <= 0 { + lines = 100 + } + + args := []string{"--output=json"} + + if opts.Since != "" { + args = append(args, "--since", opts.Since) + } + + if opts.Priority != "" { + args = append(args, "--priority", opts.Priority) + } + + args = append(args, "-n", strconv.Itoa(lines)) + + return args +} + +// buildUnitArgs constructs journalctl arguments for a unit-specific query. +// Adds -u before the line count argument. +func buildUnitArgs( + unit string, + opts QueryOpts, +) []string { + lines := opts.Lines + if lines <= 0 { + lines = 100 + } + + args := []string{"--output=json", "-u", unit} + + if opts.Since != "" { + args = append(args, "--since", opts.Since) + } + + if opts.Priority != "" { + args = append(args, "--priority", opts.Priority) + } + + args = append(args, "-n", strconv.Itoa(lines)) + + return args +} + +// parseJournalOutput parses newline-delimited JSON output from journalctl. +// Malformed or empty lines are skipped with a debug log entry. +func parseJournalOutput( + output string, + logger *slog.Logger, +) []Entry { + lines := strings.Split(output, "\n") + entries := make([]Entry, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var je journalEntry + if err := json.Unmarshal([]byte(line), &je); err != nil { + logger.Debug( + "skipping malformed journal line", + slog.String("error", err.Error()), + ) + + continue + } + + entries = append(entries, journalEntryToEntry(je)) + } + + return entries +} + +// journalEntryToEntry converts a raw journalEntry to an Entry. +func journalEntryToEntry( + je journalEntry, +) Entry { + ts := parseTimestamp(je.Timestamp) + + priority := je.Priority + if name, ok := priorityNames[je.Priority]; ok { + priority = name + } + + pid := 0 + + if je.PID != "" { + if p, err := strconv.Atoi(je.PID); err == nil { + pid = p + } + } + + return Entry{ + Timestamp: ts, + Unit: je.Unit, + Priority: priority, + Message: je.Message, + PID: pid, + Hostname: je.Hostname, + } +} + +// parseTimestamp converts a journald microsecond timestamp string to RFC3339Nano. +// Returns the original string if parsing fails. +func parseTimestamp( + usec string, +) string { + if usec == "" { + return "" + } + + micros, err := strconv.ParseInt(usec, 10, 64) + if err != nil { + return usec + } + + return time.UnixMicro(micros).UTC().Format(time.RFC3339Nano) +} diff --git a/internal/provider/node/log/linux.go b/internal/provider/node/log/linux.go new file mode 100644 index 000000000..fc945a043 --- /dev/null +++ b/internal/provider/node/log/linux.go @@ -0,0 +1,54 @@ +// 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 log + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/internal/provider" +) + +// Linux implements the Provider interface for generic Linux. +// All methods return ErrUnsupported as this is a generic Linux stub. +type Linux struct{} + +// NewLinuxProvider factory to create a new Linux instance. +func NewLinuxProvider() *Linux { + return &Linux{} +} + +// Query returns ErrUnsupported on generic Linux. +func (l *Linux) Query( + _ context.Context, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} + +// QueryUnit returns ErrUnsupported on generic Linux. +func (l *Linux) QueryUnit( + _ context.Context, + _ string, + _ QueryOpts, +) ([]Entry, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/log/linux_public_test.go b/internal/provider/node/log/linux_public_test.go new file mode 100644 index 000000000..be3a3a096 --- /dev/null +++ b/internal/provider/node/log/linux_public_test.go @@ -0,0 +1,85 @@ +// 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 log_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/provider" + oslog "github.com/retr0h/osapi/internal/provider/node/log" +) + +type LinuxPublicTestSuite struct { + suite.Suite + + provider *oslog.Linux +} + +func (suite *LinuxPublicTestSuite) SetupTest() { + suite.provider = oslog.NewLinuxProvider() +} + +func (suite *LinuxPublicTestSuite) TestQuery() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.Query(context.Background(), oslog.QueryOpts{}) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +func (suite *LinuxPublicTestSuite) TestQueryUnit() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.QueryUnit(context.Background(), "nginx.service", oslog.QueryOpts{}) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + +// 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 TestLinuxPublicTestSuite(t *testing.T) { + suite.Run(t, new(LinuxPublicTestSuite)) +} diff --git a/internal/provider/node/log/mocks/generate.go b/internal/provider/node/log/mocks/generate.go new file mode 100644 index 000000000..2c118cc16 --- /dev/null +++ b/internal/provider/node/log/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=provider.gen.go -package=mocks diff --git a/internal/provider/node/log/mocks/provider.gen.go b/internal/provider/node/log/mocks/provider.gen.go new file mode 100644 index 000000000..08f5de25c --- /dev/null +++ b/internal/provider/node/log/mocks/provider.gen.go @@ -0,0 +1,66 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: ../types.go + +// Package mocks is a generated GoMock package. +package mocks + +import ( + context "context" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + log "github.com/retr0h/osapi/internal/provider/node/log" +) + +// 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 +} + +// Query mocks base method. +func (m *MockProvider) Query(ctx context.Context, opts log.QueryOpts) ([]log.Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Query", ctx, opts) + ret0, _ := ret[0].([]log.Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Query indicates an expected call of Query. +func (mr *MockProviderMockRecorder) Query(ctx, opts interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Query", reflect.TypeOf((*MockProvider)(nil).Query), ctx, opts) +} + +// QueryUnit mocks base method. +func (m *MockProvider) QueryUnit(ctx context.Context, unit string, opts log.QueryOpts) ([]log.Entry, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryUnit", ctx, unit, opts) + ret0, _ := ret[0].([]log.Entry) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryUnit indicates an expected call of QueryUnit. +func (mr *MockProviderMockRecorder) QueryUnit(ctx, unit, opts interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryUnit", reflect.TypeOf((*MockProvider)(nil).QueryUnit), ctx, unit, opts) +} diff --git a/internal/provider/node/log/types.go b/internal/provider/node/log/types.go new file mode 100644 index 000000000..adc99d903 --- /dev/null +++ b/internal/provider/node/log/types.go @@ -0,0 +1,51 @@ +// 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 log provides log viewing operations. +package log + +import ( + "context" +) + +// Provider implements log viewing operations. +type Provider interface { + // Query returns journal entries with optional filtering. + Query(ctx context.Context, opts QueryOpts) ([]Entry, error) + // QueryUnit returns journal entries for a specific systemd unit. + QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error) +} + +// QueryOpts contains optional filters for log queries. +type QueryOpts struct { + Lines int `json:"lines,omitempty"` + Since string `json:"since,omitempty"` + Priority string `json:"priority,omitempty"` +} + +// Entry represents a single journal entry. +type Entry struct { + Timestamp string `json:"timestamp"` + Unit string `json:"unit,omitempty"` + Priority string `json:"priority"` + Message string `json:"message"` + PID int `json:"pid,omitempty"` + Hostname string `json:"hostname,omitempty"` +} From 6b2a87cc40af0f2d7f9d62d6183f52bbc971cc07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 18:42:27 -0700 Subject: [PATCH 04/19] fix(log): add debug log calls to provider methods --- internal/provider/node/log/debian.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/provider/node/log/debian.go b/internal/provider/node/log/debian.go index 15fe4efdd..3671064f0 100644 --- a/internal/provider/node/log/debian.go +++ b/internal/provider/node/log/debian.go @@ -59,6 +59,7 @@ func (d *Debian) Query( _ context.Context, opts QueryOpts, ) ([]Entry, error) { + d.logger.Debug("executing log.Query") args := buildArgs(opts) output, err := d.execManager.RunCmd("journalctl", args) @@ -75,6 +76,9 @@ func (d *Debian) QueryUnit( unit string, opts QueryOpts, ) ([]Entry, error) { + d.logger.Debug("executing log.QueryUnit", + slog.String("unit", unit), + ) args := buildUnitArgs(unit, opts) output, err := d.execManager.RunCmd("journalctl", args) From ec9301c444a3ea9614692768d952c70a9747b3ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:17:17 -0700 Subject: [PATCH 05/19] feat(log): add operations, permissions, and agent wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpLogQuery and OpLogQueryUnit operation constants to the SDK, OperationLog* aliases in the job types, PermLogRead permission across all roles, processLogOperation processor dispatching query/queryUnit sub-ops, and createLogProvider factory wired into NewNodeProcessor and the agent registry. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/agent_setup.go | 29 ++ internal/agent/export_test.go | 2 + internal/agent/fixture_public_test.go | 1 + internal/agent/processor.go | 4 + internal/agent/processor_log.go | 110 ++++++ internal/agent/processor_log_public_test.go | 339 ++++++++++++++++++ internal/agent/processor_ntp_public_test.go | 5 + .../agent/processor_package_public_test.go | 1 + internal/agent/processor_power_public_test.go | 3 + .../agent/processor_process_public_test.go | 4 + .../agent/processor_sysctl_public_test.go | 6 + .../agent/processor_timezone_public_test.go | 3 + internal/agent/processor_user_public_test.go | 1 + internal/authtoken/permissions.go | 5 + internal/job/types.go | 6 + pkg/sdk/client/operations.go | 6 + pkg/sdk/client/permissions.go | 1 + 17 files changed, 526 insertions(+) create mode 100644 internal/agent/processor_log.go create mode 100644 internal/agent/processor_log_public_test.go diff --git a/cmd/agent_setup.go b/cmd/agent_setup.go index e98e9ab5c..969dbab72 100644 --- a/cmd/agent_setup.go +++ b/cmd/agent_setup.go @@ -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" @@ -205,6 +206,9 @@ func setupAgent( // --- Package provider --- packageProvider := createPackageProvider(log, execManager) + // --- Log provider --- + logProvider := createLogProvider(log, execManager) + // --- Build registry --- registry := agent.NewProviderRegistry() @@ -222,6 +226,7 @@ func setupAgent( processProvider, userProvider, packageProvider, + logProvider, appConfig, log, ), @@ -236,6 +241,7 @@ func setupAgent( processProvider, userProvider, packageProvider, + logProvider, ) registry.Register("network", @@ -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() + } +} diff --git a/internal/agent/export_test.go b/internal/agent/export_test.go index 5a056e05d..74ff9041b 100644 --- a/internal/agent/export_test.go +++ b/internal/agent/export_test.go @@ -364,6 +364,7 @@ func SetAgentAppConfig( nil, nil, nil, + nil, cfg, a.logger, ) @@ -400,6 +401,7 @@ func SetAgentHostProvider( nil, nil, nil, + nil, a.appConfig, a.logger, ) diff --git a/internal/agent/fixture_public_test.go b/internal/agent/fixture_public_test.go index 7da902c25..88aeb810f 100644 --- a/internal/agent/fixture_public_test.go +++ b/internal/agent/fixture_public_test.go @@ -104,6 +104,7 @@ func newTestAgent(p newTestAgentParams) *agent.Agent { nil, nil, nil, + nil, p.appConfig, logger, ), diff --git a/internal/agent/processor.go b/internal/agent/processor.go index 08505c568..1376bab21 100644 --- a/internal/agent/processor.go +++ b/internal/agent/processor.go @@ -35,6 +35,7 @@ import ( "github.com/retr0h/osapi/internal/provider/node/mem" "github.com/retr0h/osapi/internal/provider/node/ntp" "github.com/retr0h/osapi/internal/provider/node/power" + logProv "github.com/retr0h/osapi/internal/provider/node/log" processProv "github.com/retr0h/osapi/internal/provider/node/process" "github.com/retr0h/osapi/internal/provider/node/sysctl" "github.com/retr0h/osapi/internal/provider/node/timezone" @@ -66,6 +67,7 @@ func NewNodeProcessor( processProvider processProv.Provider, userProvider user.Provider, packageProvider apt.Provider, + logProvider logProv.Provider, appConfig config.Config, logger *slog.Logger, ) ProcessorFunc { @@ -107,6 +109,8 @@ func NewNodeProcessor( return processGroupOperation(userProvider, logger, req) case "package": return processPackageOperation(packageProvider, logger, req) + case "log": + return processLogOperation(logProvider, logger, req) default: return nil, fmt.Errorf("unsupported node operation: %s", req.Operation) } diff --git a/internal/agent/processor_log.go b/internal/agent/processor_log.go new file mode 100644 index 000000000..37d63bf00 --- /dev/null +++ b/internal/agent/processor_log.go @@ -0,0 +1,110 @@ +// 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" + "fmt" + "log/slog" + "strings" + + "github.com/retr0h/osapi/internal/job" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +// processLogOperation dispatches log management sub-operations. +func processLogOperation( + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + if logProvider == nil { + return nil, fmt.Errorf("log provider not available") + } + + // Extract sub-operation: "log.query" -> "query" + parts := strings.Split(jobRequest.Operation, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("invalid log operation: %s", jobRequest.Operation) + } + subOp := parts[1] + + ctx := context.Background() + + switch subOp { + case "query": + return processLogQuery(ctx, logProvider, logger, jobRequest) + case "queryUnit": + return processLogQueryUnit(ctx, logProvider, logger, jobRequest) + default: + return nil, fmt.Errorf("unsupported log operation: %s", jobRequest.Operation) + } +} + +// processLogQuery retrieves journal entries with optional filtering. +func processLogQuery( + ctx context.Context, + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + logger.Debug("executing log.Query") + + var opts logProv.QueryOpts + if len(jobRequest.Data) > 0 { + if err := json.Unmarshal(jobRequest.Data, &opts); err != nil { + return nil, fmt.Errorf("unmarshal log query data: %w", err) + } + } + + result, err := logProvider.Query(ctx, opts) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} + +// processLogQueryUnit retrieves journal entries for a specific systemd unit. +func processLogQueryUnit( + ctx context.Context, + logProvider logProv.Provider, + logger *slog.Logger, + jobRequest job.Request, +) (json.RawMessage, error) { + logger.Debug("executing log.QueryUnit") + + var data struct { + Unit string `json:"unit"` + logProv.QueryOpts + } + if err := json.Unmarshal(jobRequest.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshal log query unit data: %w", err) + } + + result, err := logProvider.QueryUnit(ctx, data.Unit, data.QueryOpts) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} diff --git a/internal/agent/processor_log_public_test.go b/internal/agent/processor_log_public_test.go new file mode 100644 index 000000000..64c9984b3 --- /dev/null +++ b/internal/agent/processor_log_public_test.go @@ -0,0 +1,339 @@ +// 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_test + +import ( + "encoding/json" + "errors" + "log/slog" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/agent" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/node/log" + logMocks "github.com/retr0h/osapi/internal/provider/node/log/mocks" +) + +type ProcessorLogPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller +} + +func (s *ProcessorLogPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) +} + +func (s *ProcessorLogPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *ProcessorLogPublicTestSuite) newProcessor( + logProvider log.Provider, +) agent.ProcessorFunc { + return agent.NewNodeProcessor( + nil, nil, nil, nil, + nil, nil, nil, nil, + nil, + nil, + nil, + logProvider, + config.Config{}, + slog.Default(), + ) +} + +func (s *ProcessorLogPublicTestSuite) TestProcessLogOperation() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() log.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "nil provider returns error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + }, + setupMock: nil, + expectError: true, + errorMsg: "log provider not available", + }, + { + name: "invalid operation format (no sub-operation)", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log", + }, + setupMock: func() log.Provider { + return logMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "invalid log operation: log", + }, + { + name: "unsupported log sub-operation", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.invalid", + }, + setupMock: func() log.Provider { + return logMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unsupported log operation: log.invalid", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + var logProvider log.Provider + if tt.setupMock != nil { + logProvider = tt.setupMock() + } + + processor := s.newProcessor(logProvider) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorLogPublicTestSuite) TestProcessLogQuery() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() log.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "query with default opts (empty data)", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + Data: json.RawMessage(`{}`), + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().Query(gomock.Any(), log.QueryOpts{}).Return([]log.Entry{ + { + Timestamp: "2026-01-01T00:00:00Z", + Unit: "systemd", + Priority: "info", + Message: "Started system", + }, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var entries []log.Entry + err := json.Unmarshal(result, &entries) + s.NoError(err) + s.Len(entries, 1) + s.Equal("Started system", entries[0].Message) + }, + }, + { + name: "query with all options", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + Data: json.RawMessage(`{"lines":50,"since":"1 hour ago","priority":"err"}`), + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().Query(gomock.Any(), log.QueryOpts{ + Lines: 50, + Since: "1 hour ago", + Priority: "err", + }).Return([]log.Entry{ + { + Timestamp: "2026-01-01T00:00:00Z", + Priority: "err", + Message: "error occurred", + }, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var entries []log.Entry + err := json.Unmarshal(result, &entries) + s.NoError(err) + s.Len(entries, 1) + s.Equal("err", entries[0].Priority) + }, + }, + { + name: "query provider error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + Data: json.RawMessage(`{}`), + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().Query(gomock.Any(), gomock.Any()).Return(nil, errors.New("journalctl failed")) + return m + }, + expectError: true, + errorMsg: "journalctl failed", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() log.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "queryUnit with unit name", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.queryUnit", + Data: json.RawMessage(`{"unit":"nginx.service"}`), + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().QueryUnit(gomock.Any(), "nginx.service", log.QueryOpts{}).Return([]log.Entry{ + { + Timestamp: "2026-01-01T00:00:00Z", + Unit: "nginx.service", + Priority: "info", + Message: "nginx started", + }, + }, nil) + return m + }, + validate: func(result json.RawMessage) { + var entries []log.Entry + err := json.Unmarshal(result, &entries) + s.NoError(err) + s.Len(entries, 1) + s.Equal("nginx.service", entries[0].Unit) + s.Equal("nginx started", entries[0].Message) + }, + }, + { + name: "queryUnit unmarshal error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.queryUnit", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() log.Provider { + return logMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal log query unit data", + }, + { + name: "queryUnit provider error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.queryUnit", + Data: json.RawMessage(`{"unit":"nginx.service"}`), + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT().QueryUnit(gomock.Any(), "nginx.service", gomock.Any()).Return(nil, errors.New("unit not found")) + return m + }, + expectError: true, + errorMsg: "unit not found", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + +func TestProcessorLogPublicTestSuite(t *testing.T) { + suite.Run(t, new(ProcessorLogPublicTestSuite)) +} diff --git a/internal/agent/processor_ntp_public_test.go b/internal/agent/processor_ntp_public_test.go index 81f96dd1a..debc4a71b 100644 --- a/internal/agent/processor_ntp_public_test.go +++ b/internal/agent/processor_ntp_public_test.go @@ -114,6 +114,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpOperation() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -197,6 +198,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpGet() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -292,6 +294,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpCreate() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -387,6 +390,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpUpdate() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -464,6 +468,7 @@ func (s *ProcessorNtpPublicTestSuite) TestProcessNtpDelete() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_package_public_test.go b/internal/agent/processor_package_public_test.go index fc247e730..e54518114 100644 --- a/internal/agent/processor_package_public_test.go +++ b/internal/agent/processor_package_public_test.go @@ -59,6 +59,7 @@ func (s *ProcessorPackagePublicTestSuite) newProcessor( nil, nil, packageProvider, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_power_public_test.go b/internal/agent/processor_power_public_test.go index 1e7f7a0d5..969c6b84c 100644 --- a/internal/agent/processor_power_public_test.go +++ b/internal/agent/processor_power_public_test.go @@ -115,6 +115,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerOperation() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -241,6 +242,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerReboot() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -367,6 +369,7 @@ func (s *ProcessorPowerPublicTestSuite) TestProcessPowerShutdown() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_process_public_test.go b/internal/agent/processor_process_public_test.go index bc4755e15..e4f90dbb4 100644 --- a/internal/agent/processor_process_public_test.go +++ b/internal/agent/processor_process_public_test.go @@ -111,6 +111,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessOperation() { processProvider, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -202,6 +203,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessList() { tt.setupMock(), nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -299,6 +301,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessGet() { tt.setupMock(), nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -396,6 +399,7 @@ func (s *ProcessorProcessPublicTestSuite) TestProcessProcessSignal() { tt.setupMock(), nil, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_sysctl_public_test.go b/internal/agent/processor_sysctl_public_test.go index 5c4f79e4d..b72c45a55 100644 --- a/internal/agent/processor_sysctl_public_test.go +++ b/internal/agent/processor_sysctl_public_test.go @@ -115,6 +115,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlOperation() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -196,6 +197,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlList() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -290,6 +292,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlGet() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -389,6 +392,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlCreate() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -488,6 +492,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlUpdate() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -582,6 +587,7 @@ func (s *ProcessorSysctlPublicTestSuite) TestProcessSysctlDelete() { nil, nil, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_timezone_public_test.go b/internal/agent/processor_timezone_public_test.go index 233a761d0..bd288b93c 100644 --- a/internal/agent/processor_timezone_public_test.go +++ b/internal/agent/processor_timezone_public_test.go @@ -114,6 +114,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneOperation() { nil, nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -193,6 +194,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneGet() { nil, nil, nil, nil, + nil, config.Config{}, slog.Default(), ) @@ -288,6 +290,7 @@ func (s *ProcessorTimezonePublicTestSuite) TestProcessTimezoneUpdate() { nil, nil, nil, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/agent/processor_user_public_test.go b/internal/agent/processor_user_public_test.go index 5f3c73dd8..2d6aa3af0 100644 --- a/internal/agent/processor_user_public_test.go +++ b/internal/agent/processor_user_public_test.go @@ -59,6 +59,7 @@ func (s *ProcessorUserPublicTestSuite) newProcessor( nil, userProvider, nil, + nil, config.Config{}, slog.Default(), ) diff --git a/internal/authtoken/permissions.go b/internal/authtoken/permissions.go index 716272872..28c4dcb09 100644 --- a/internal/authtoken/permissions.go +++ b/internal/authtoken/permissions.go @@ -58,6 +58,7 @@ const ( PermUserWrite = client.PermUserWrite PermPackageRead = client.PermPackageRead PermPackageWrite = client.PermPackageWrite + PermLogRead = client.PermLogRead ) // AllPermissions is the full set of known permissions. @@ -93,6 +94,7 @@ var AllPermissions = []Permission{ PermUserWrite, PermPackageRead, PermPackageWrite, + PermLogRead, } // DefaultRolePermissions maps built-in role names to their granted permissions. @@ -129,6 +131,7 @@ var DefaultRolePermissions = map[string][]Permission{ PermUserWrite, PermPackageRead, PermPackageWrite, + PermLogRead, }, client.RoleWrite: { PermAgentRead, @@ -156,6 +159,7 @@ var DefaultRolePermissions = map[string][]Permission{ PermUserWrite, PermPackageRead, PermPackageWrite, + PermLogRead, }, client.RoleRead: { PermAgentRead, @@ -172,6 +176,7 @@ var DefaultRolePermissions = map[string][]Permission{ PermProcessRead, PermUserRead, PermPackageRead, + PermLogRead, }, } diff --git a/internal/job/types.go b/internal/job/types.go index 068810766..3541cc502 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -219,6 +219,12 @@ const ( OperationPackageListUpdates = client.OpPackageListUpdates ) +// Log operations. +const ( + OperationLogQuery = client.OpLogQuery + OperationLogQueryUnit = client.OpLogQueryUnit +) + // Operation represents an operation in the new hierarchical format type Operation struct { // Type specifies the type of operation using hierarchical format diff --git a/pkg/sdk/client/operations.go b/pkg/sdk/client/operations.go index d504526fa..24eed5256 100644 --- a/pkg/sdk/client/operations.go +++ b/pkg/sdk/client/operations.go @@ -155,6 +155,12 @@ const ( OpPackageListUpdates JobOperation = "node.package.listUpdates" ) +// Log operations. +const ( + OpLogQuery JobOperation = "node.log.query" + OpLogQueryUnit JobOperation = "node.log.queryUnit" +) + // Target constants for job routing. const ( // TargetAny routes to any available agent (load-balanced). diff --git a/pkg/sdk/client/permissions.go b/pkg/sdk/client/permissions.go index 0e0ce7150..107ab77bf 100644 --- a/pkg/sdk/client/permissions.go +++ b/pkg/sdk/client/permissions.go @@ -58,6 +58,7 @@ const ( PermUserWrite Permission = "user:write" PermPackageRead Permission = "package:read" PermPackageWrite Permission = "package:write" + PermLogRead Permission = "log:read" ) // Role represents a built-in RBAC role name. From 9ada19bec89e45a5c1af3e481575a0e15568ac5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:20:19 -0700 Subject: [PATCH 06/19] feat(log): add OpenAPI spec and generated code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenAPI spec for log management domain with two endpoints: GET /node/{hostname}/log and GET /node/{hostname}/log/unit/{name}. Regenerate combined spec and SDK client with new log types. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/gen/api/get-node-log-unit.api.mdx | 654 ++++++++++++++++++ docs/docs/gen/api/get-node-log.api.mdx | 649 +++++++++++++++++ .../log-management-api-log-operations.tag.mdx | 20 + docs/docs/gen/api/sidebar.ts | 22 + internal/controller/api/gen/api.yaml | 219 ++++++ internal/controller/api/node/log/gen/api.yaml | 281 ++++++++ internal/controller/api/node/log/gen/cfg.yaml | 32 + .../controller/api/node/log/gen/generate.go | 24 + .../controller/api/node/log/gen/log.gen.go | 411 +++++++++++ pkg/sdk/client/gen/client.gen.go | 471 ++++++++++++- 10 files changed, 2778 insertions(+), 5 deletions(-) create mode 100644 docs/docs/gen/api/get-node-log-unit.api.mdx create mode 100644 docs/docs/gen/api/get-node-log.api.mdx create mode 100644 docs/docs/gen/api/log-management-api-log-operations.tag.mdx create mode 100644 internal/controller/api/node/log/gen/api.yaml create mode 100644 internal/controller/api/node/log/gen/cfg.yaml create mode 100644 internal/controller/api/node/log/gen/generate.go create mode 100644 internal/controller/api/node/log/gen/log.gen.go diff --git a/docs/docs/gen/api/get-node-log-unit.api.mdx b/docs/docs/gen/api/get-node-log-unit.api.mdx new file mode 100644 index 000000000..071feec99 --- /dev/null +++ b/docs/docs/gen/api/get-node-log-unit.api.mdx @@ -0,0 +1,654 @@ +--- +id: get-node-log-unit +title: "Get log entries for a systemd unit" +description: "Retrieve log entries for a specific systemd unit on the target node." +sidebar_label: "Get log entries for a systemd unit" +hide_title: true +hide_table_of_contents: true +api: eJztWG1v2zYQ/isEP6WA7Eipk20C+iHbmi5DOhRpigFLDYMWzzITilRJKo1n+L8PR0qyZLut3aXYPhQIEEkmj88998pbUg42M6J0Qiua0mtwRsADEKlzAgpfLJlpQxixJWRiJjJiF9ZBwUmlhCNaETcH4pjJwRGlOQzfKxpRx3JL01t6pfPJa6ZYDgUoNzl/czmROp/oEgzDMy0dR7R9u+Q0pa/A/aE5XOn8nRKORtRCVhnhFjS9XdKfgRkw55Wbo3ip89QA43S8Gke0ZIYV4MBYv1SxAmhK59o6/xhRgTqWzM1pRA18qIQBTlNnKog2iLgJCrEclCONhIgYsGAegBOjKydUTh6YrIAcTZhaRGTCpHwWEW2IZFOQxIKEzGlDju5hkfqlzwI9jwPNSjHINIcc1AAenWGDwNmSPjApOHOIvQEZFUK9SCL/yySQTVcRtdkcCoZ73KLE9dYZoXIa0UKoK1A5spSsVlFLxsFEvO1aG3eTIxjmw4i8pyoX6nGIhIgM3lP8ZO2cr7/8K2W/Tj8pFNhGwQ8VmEVPwxmTdkvF1+xRFFVBVFVMwRA9897vJRGniQFXGXWYKroQDorSLWrDFezxRRLHcbxTK6Ec5GAoApuxSjqaJnHslURgNE0iWgSQ/pc47qpshcrgQJWvvU69KPdiiJsLS5woYEjOswxKZ9t4x69tFgjBu/aFZB4c4CQ+ORvEySBOSByn/q/1g09Zs6tMaYT2sX6YPhdCOjBkuvAqNUKIhAeQa5BgTED5kRklVB5ehJrp8MRhWuV7wB0jHltqZcEb/iSO8V8f0tVGBsU0WbMHIZyGNKKZVg6Uw+2sLGVN7PGdRRnLbQx6egcZJsXSYNZ0IiC409OJ4LsCZaZNwRxNaVUJTrfS3BzInZ6Sy19JZYGju5dGZ2Bt8ASkHaxHCo+sKCXKPj2N4cdRHA/g5KfpYJTw0YD9kJwNRqOzs9PT0ajxcwO2ks52UDFjmDetg8Lu0mqbwiCkqUFC5RJCVh5ukdAm+h00bKvdrMZ4R9vUQjE+HXOV3SUFFEbgLdX3yCwTEpBSey/KEjhWsu1jgrDmkLbS1S4hrMfhj62dZQddn3Eso4sgpuVkT27PGzKbHLBohEEd8eROV0YxuU00ZgLrWFF+memrVnq7qe9MTcJITm+SOH2OCeMvZAMD5Mvie9XJzZlD9+VVhq6MrPij+wf2qhae1KacvZTp55a+aMwkKLEAa1m+hx+iwHoxcfC4EWdvsdUw6EHGASe2yjAwZ5WUC4+7F/DdGtI94k0dzoKDcph7zH48JSfPR6vogJj6bSOeaifazygfYTqIE6wE1AnnP13p/CUuvPSkYnwYo82XcbzEZS2rohPcJETs0EtbF5TbbodYx/64h+Pa5yCPhq42NzdZrr/lFy2x9xNaXdeVAneuIjqKk+1a8U6xys21EX8DJwNy/uaS3MOCtMc8WZ3Yk8Nz0nlvDOr3BnvqLKuMQSq7Rrzw9NYtU7hGBDp9euPgmJA70+rG4ZwLfGSS1HsIm+rKrUHsPJZXgEcrcB+1uffpRlchs2K7tkeo3LRK4obeIae+5WoN7H1sy7DPtw17oc1UcA6KDMilstVsJjKBrliCKYS1/gb03br/f+ue7mrxQqqp6cDboC9Dnab6CTu877b9Rrb19drNNY4e8FodhXtxSo9xmnG8bIrD6ljq/BgtfLz0734ygRUapw3j9ZjiLVo0GK07rGiVmDtXNtcLfJ/6RTSqHy6ahv33P298pfJdhe+6ggrnvpathypYLmhEEUhgIxnGQ9+Al9q6gnk3q69Xr8DtGu10uqhNbpdrB37C6VBgAlue41IyoXzHZyQeF9i/pbiaRjTtFGep0edqlKn/OA4tCm5YLqfMwjsjVyv8HG6OOAfiwrKp/NTdsavgAZOAnSrcw6Izg/ADH5pSivfb/UH8J3fzz6jTzBe+Up1vcTX/DNrOAGENeIwvRiDiAz3i6LruwZ6RbzwS3KlTcxFUPX0aXdvQOMwiHZ2eYLr3FbgD5jFeL4BxMN4m4afg2J1NWzUTjdkm7Fcvb2hEWT/BbiRUL30noOUyrLjR96BWqxafw3cEuFr9A6ec7TY= +sidebar_class_name: "get api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Retrieve log entries for a specific systemd unit on the target node. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + + + +
+
+
+ +

+ Query Parameters +

+
+
    + + + + + + + +
+
+
+
+ + +
+ + + Log entries for the specified unit. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + +
    + + + + entries + + object[] + + +
    +
    + + + Log entries from this agent. + + +
  • +
    + Array [ +
    +
  • + + + + + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error retrieving unit log entries. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/get-node-log.api.mdx b/docs/docs/gen/api/get-node-log.api.mdx new file mode 100644 index 000000000..e559cffc5 --- /dev/null +++ b/docs/docs/gen/api/get-node-log.api.mdx @@ -0,0 +1,649 @@ +--- +id: get-node-log +title: "Get system log entries" +description: "Retrieve log entries from the target node's system journal." +sidebar_label: "Get system log entries" +hide_title: true +hide_table_of_contents: true +api: eJztWG1v2zYQ/isEvywFZEdKnWwT0A/Z1nYZ0qFIUwxYYhi0dJaZUKRKntJ4hv/7cKQky7HbOFuL7UMBA5Zs8vjcc+9c8hxcZmWF0mie8gtAK+EOmDIFA00vjs2sKRnOgaGwBSDTJofvHHMLh1CyG1NbLdTwWvOIoygcT6/4uSkmb4QWBZSgcXL69myiTDExFVhBRzk+jnj3dpbzlL8G/N3kcG4KHnEHWW0lLnh6teQ/gbBgT2uck2hlitSCyPl4NY54JawoAcE6v1SLEnjK58ahf4y4JLUqgXMecQsfamkh5ynaGqIHul8G7UQBGlkrIWIWHNg7yJk1NUpdsDuhamAHE6EXEZsIpZ5FzFimxBQUc6AgQ2PZwS0sUr/0WaDmfmBEJQeZyaEAPYB7tGIQ+FryO6FkLpCwtyCjUuoXSeT/mQTm+SriLptDKWgPLipa79BKTZyVUp+DLoilZLWKOjKU1OBaJj7UYBcbVMyEcltcvBH3sqxLputyCpaZmXcIL4mhYRawtvppeplSIpQVLhrFSnH/IonjON6pldQIBVhOwGaiVsjTJI69kgSMp0nEywDS/xPHfZWd1Bk8UeULr9OG43sxDOfSMZQlDNlplkGFrevn/lfmKsjkTGbBsdkBDIthxK55Mr/m9H0UH50M4mQQJyyOU/+55o1TfMqafWUqK42Phafp80oqBMumC69SK4QpuAO1BgnWBpQfhdVSF+FF6pkJTzlM62IPuGPC4yqjHXjDH8UxfW1COn8kqQx5xDOjETTSZlFVqqH18MaRhOU2AjO9gQx5xCtL+QRlOP/GTCcy3xUmM2NLgTzldS1zvpUE5sBuzJSd/cJqBzk5e2VNBs4FPyDSwSEhhXtRVopkHx/H8MMojgdw9ON0MEry0UB8n5wMRqOTk+Pj0aj1cguuVuh6qIS1whsWoXS7tNomMAhhM2OZIA8tFIScNdwioUuDO2jYVrtdTdFOhmmEUnSiwNrtkgKa4u+Km1tiVkgFRKm7lVUFOeX47WOCsPaQrgZ4hTzFhMMf27jKDroedSvp1pzsye1pS2abARZrH31Q6raIpjzgUJTV40yfd9K7TZvO1KaL5PgyidPnlC7+JDZqLfFx8e+azESrGc4FkvvmdUauTKz4ozcP1IXU90MqcjIDOqlLOHsps5lZNkVTHiGJJTgnij38kAQ2ixnC/YM4e0eF2JIHWYScuTqjwJzVSi087o2A71eQ/hFvm3CWOWiUMwl2P56So+ejVfSEmPr1QTw1TrSfUT7CdBAnVAc4SvQ/nZviJS0886RSfFhr7OM4XtKyjlXZC24WInbopa3LyVW/f2pif7yB48LnII+Grx5ubrPc5pafjaLOSBp90dQJ2rmK+ChOtivFey1qnBsr/4KcDdjp2zN2CwvWHfPF6sSeHJ6y3ntrUL832NNkWW0tUdk34itPb9Mwhb460OnTWw4opNqZVh8cnueSHoVizR4mpqbGNYidx+Y10NEa8KOxtz7dmDpkVmrW9giVy07JrKnO3SHHvuHqDOx9bMuwz7cN+8rYqcxz0GzAzrSrZzOZSXLFCmwpnfOzwTfr/v+te7yrwQuppqGDZqVeN/0Fm7tvZv1KZvWlGueG5nGaN6MwOaf8kNrzw2VbF1aHqhnTqSDT6D1ez+zvyIrBUP3JvQM+R6zaWYLep34Rj5qHV21//tsfl74w+SbCN1kB9qkvXevbBaoOPOIEJDCQDOOh77cr47AU3rWaWeo1YFuHe675kMPl2lH//Y1IUJqamcNKCal9L2cVnRLIveK0nUc87ZVd4nccug1asVxOhYP3Vq1W9HMYAenCI5dOTNWnhsC+Ik8Y6XdivoVF7zLB32zwlHMaVPcH8Z8M2Z9Rp70o+IfqfI0Z+zNoezcBa8BjerGSED/RIw4umnbqGfvKd187dWpnOr2hT6trFwurMXXdIHKwXr/wd3CS3satekLEdMns9ctLHnGxmYgeJB4vfSeo5TKsuDS3oFerDiPSOwFcrf4G1IRNvw== +sidebar_class_name: "get api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +Retrieve log entries from the target node's system journal. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + +
+
+
+ +

+ Query Parameters +

+
+
    + + + + + + + +
+
+
+
+ + +
+ + + Log entries from the target node. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + +
    + + + + entries + + object[] + + +
    +
    + + + Log entries from this agent. + + +
  • +
    + Array [ +
    +
  • + + + + + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error retrieving log entries. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/log-management-api-log-operations.tag.mdx b/docs/docs/gen/api/log-management-api-log-operations.tag.mdx new file mode 100644 index 000000000..a84080631 --- /dev/null +++ b/docs/docs/gen/api/log-management-api-log-operations.tag.mdx @@ -0,0 +1,20 @@ +--- +id: log-management-api-log-operations +title: "Node/Log" +description: "Node/Log" +custom_edit_url: null +--- + + + +Log viewing operations on a target node. + + + +```mdx-code-block +import DocCardList from '@theme/DocCardList'; +import {useCurrentSidebarCategory} from '@docusaurus/theme-common'; + + +``` + \ No newline at end of file diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts index aaf463cff..1bd0c6c44 100644 --- a/docs/docs/gen/api/sidebar.ts +++ b/docs/docs/gen/api/sidebar.ts @@ -408,6 +408,28 @@ const sidebar: SidebarsConfig = { }, ], }, + { + type: "category", + label: "Node/Log", + link: { + type: "doc", + id: "gen/api/log-management-api-log-operations", + }, + items: [ + { + type: "doc", + id: "gen/api/get-node-log", + label: "Get system log entries", + className: "api-method get", + }, + { + type: "doc", + id: "gen/api/get-node-log-unit", + label: "Get log entries for a systemd unit", + className: "api-method get", + }, + ], + }, { type: "category", label: "Node/Network", diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml index 6c891cc79..f3e622b6f 100644 --- a/internal/controller/api/gen/api.yaml +++ b/internal/controller/api/gen/api.yaml @@ -48,6 +48,9 @@ tags: - name: Hostname_Management_API_hostname_operations x-displayName: Node/Hostname description: Hostname operations on a target node. + - name: Log_Management_API_log_operations + x-displayName: Node/Log + description: Log viewing operations on a target node. - name: Network_Management_API_network_operations x-displayName: Node/Network description: Network operations on a target node. @@ -2199,6 +2202,141 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/log: + servers: [] + get: + summary: Get system log entries + description: | + Retrieve log entries from the target node's system journal. + tags: + - Log_Management_API_log_operations + operationId: GetNodeLog + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - name: lines + in: query + required: false + description: | + Maximum number of log lines to return. + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Return log entries since this time. Accepts systemd time + specifications (e.g., "1h", "2026-01-01 00:00:00"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Filter by log priority level (e.g., "err", "warning", "info", + "debug"). + schema: + type: string + responses: + '200': + description: Log entries from the target node. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving log entries. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/log/unit/{name}: + servers: [] + get: + summary: Get log entries for a systemd unit + description: | + Retrieve log entries for a specific systemd unit on the target node. + tags: + - Log_Management_API_log_operations + operationId: GetNodeLogUnit + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UnitName' + - name: lines + in: query + required: false + description: | + Maximum number of log lines to return. + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Return log entries since this time. Accepts systemd time + specifications (e.g., "1h", "2026-01-01 00:00:00"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Filter by log priority level (e.g., "err", "warning", "info", + "debug"). + schema: + type: string + responses: + '200': + description: Log entries for the specified unit. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error retrieving unit log entries. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /node/{hostname}/network/ping: servers: [] post: @@ -6192,6 +6330,73 @@ components: $ref: '#/components/schemas/HostnameUpdateResultItem' required: - results + LogEntryInfo: + type: object + description: A single log entry from the system journal. + properties: + timestamp: + type: string + description: Log entry timestamp. + example: '2026-01-15T10:30:00Z' + unit: + type: string + description: Systemd unit that produced this entry. + example: nginx.service + priority: + type: string + description: Log priority level. + example: info + message: + type: string + description: Log message text. + example: Server started successfully + pid: + type: integer + description: Process identifier that produced this entry. + example: 1234 + hostname: + type: string + description: Hostname of the system that produced this entry. + example: web-01 + LogResultEntry: + type: object + description: Log result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + entries: + type: array + description: Log entries from this agent. + items: + $ref: '#/components/schemas/LogEntryInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + LogCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/LogResultEntry' + required: + - results PingResponse: type: object properties: @@ -7730,6 +7935,17 @@ components: type: string minLength: 1 pattern: ^[a-zA-Z0-9][a-zA-Z0-9_.-]*$ + UnitName: + name: name + in: path + required: true + description: | + Systemd unit name (e.g., "nginx.service", "sshd.service"). + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 PackageName: name: name in: path @@ -7839,6 +8055,9 @@ x-tagGroups: - name: Hostname Management API tags: - Hostname_Management_API_hostname_operations + - name: Log Management API + tags: + - Log_Management_API_log_operations - name: Network Management API tags: - Network_Management_API_network_operations diff --git a/internal/controller/api/node/log/gen/api.yaml b/internal/controller/api/node/log/gen/api.yaml new file mode 100644 index 000000000..b81b11306 --- /dev/null +++ b/internal/controller/api/node/log/gen/api.yaml @@ -0,0 +1,281 @@ +# 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. + +--- +openapi: 3.0.0 +info: + title: Log Management API + version: 1.0.0 +tags: + - name: log_operations + x-displayName: Node/Log + description: Log viewing operations on a target node. + +paths: + /node/{hostname}/log: + get: + summary: Get system log entries + description: > + Retrieve log entries from the target node's system journal. + tags: + - log_operations + operationId: GetNodeLog + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - name: lines + in: query + required: false + description: > + Maximum number of log lines to return. + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Return log entries since this time. Accepts systemd time + specifications (e.g., "1h", "2026-01-01 00:00:00"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Filter by log priority level (e.g., "err", "warning", "info", + "debug"). + schema: + type: string + responses: + '200': + description: Log entries from the target node. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error retrieving log entries. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + /node/{hostname}/log/unit/{name}: + get: + summary: Get log entries for a systemd unit + description: > + Retrieve log entries for a specific systemd unit on the target node. + tags: + - log_operations + operationId: GetNodeLogUnit + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + - $ref: '#/components/parameters/UnitName' + - name: lines + in: query + required: false + description: > + Maximum number of log lines to return. + x-oapi-codegen-extra-tags: + validate: omitempty,min=1,max=10000 + schema: + type: integer + default: 100 + minimum: 1 + maximum: 10000 + - name: since + in: query + required: false + description: > + Return log entries since this time. Accepts systemd time + specifications (e.g., "1h", "2026-01-01 00:00:00"). + schema: + type: string + - name: priority + in: query + required: false + description: > + Filter by log priority level (e.g., "err", "warning", "info", + "debug"). + schema: + type: string + responses: + '200': + description: Log entries for the specified unit. + content: + application/json: + schema: + $ref: '#/components/schemas/LogCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error retrieving unit log entries. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + +# -- Reusable components -- + +components: + parameters: + Hostname: + name: hostname + in: path + required: true + description: > + Target agent hostname, reserved routing value (_any, _all), + or label selector (key:value). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in handlers via validateHostname(). + x-oapi-codegen-extra-tags: + validate: required,min=1,valid_target + schema: + type: string + minLength: 1 + + UnitName: + name: name + in: path + required: true + description: > + Systemd unit name (e.g., "nginx.service", "sshd.service"). + # NOTE: x-oapi-codegen-extra-tags on path params do not generate + # validate tags in strict-server mode. Validation is handled + # manually in the handler. + x-oapi-codegen-extra-tags: + validate: required,min=1 + schema: + type: string + minLength: 1 + + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ErrorResponse: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + + # -- Response schemas -- + + LogEntryInfo: + type: object + description: A single log entry from the system journal. + properties: + timestamp: + type: string + description: Log entry timestamp. + example: "2026-01-15T10:30:00Z" + unit: + type: string + description: Systemd unit that produced this entry. + example: "nginx.service" + priority: + type: string + description: Log priority level. + example: "info" + message: + type: string + description: Log message text. + example: "Server started successfully" + pid: + type: integer + description: Process identifier that produced this entry. + example: 1234 + hostname: + type: string + description: Hostname of the system that produced this entry. + example: "web-01" + + LogResultEntry: + type: object + description: Log result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + entries: + type: array + description: Log entries from this agent. + items: + $ref: '#/components/schemas/LogEntryInfo' + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + # -- Collection responses -- + + LogCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/LogResultEntry' + required: + - results diff --git a/internal/controller/api/node/log/gen/cfg.yaml b/internal/controller/api/node/log/gen/cfg.yaml new file mode 100644 index 000000000..be6c98be7 --- /dev/null +++ b/internal/controller/api/node/log/gen/cfg.yaml @@ -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: gen +output: log.gen.go +generate: + models: true + echo-server: true + strict-server: true +import-mapping: + ../../../common/gen/api.yaml: github.com/retr0h/osapi/internal/controller/api/common/gen +output-options: + # to make sure that all types are generated + skip-prune: true diff --git a/internal/controller/api/node/log/gen/generate.go b/internal/controller/api/node/log/gen/generate.go new file mode 100644 index 000000000..e3b68476d --- /dev/null +++ b/internal/controller/api/node/log/gen/generate.go @@ -0,0 +1,24 @@ +// 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 gen contains generated code for the log API. +package gen + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml api.yaml diff --git a/internal/controller/api/node/log/gen/log.gen.go b/internal/controller/api/node/log/gen/log.gen.go new file mode 100644 index 000000000..d359b8468 --- /dev/null +++ b/internal/controller/api/node/log/gen/log.gen.go @@ -0,0 +1,411 @@ +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package gen + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" + openapi_types "github.com/oapi-codegen/runtime/types" + externalRef0 "github.com/retr0h/osapi/internal/controller/api/common/gen" +) + +const ( + BearerAuthScopes = "BearerAuth.Scopes" +) + +// Defines values for LogResultEntryStatus. +const ( + Failed LogResultEntryStatus = "failed" + Ok LogResultEntryStatus = "ok" + Skipped LogResultEntryStatus = "skipped" +) + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse = externalRef0.ErrorResponse + +// LogCollectionResponse defines model for LogCollectionResponse. +type LogCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []LogResultEntry `json:"results"` +} + +// LogEntryInfo A single log entry from the system journal. +type LogEntryInfo struct { + // Hostname Hostname of the system that produced this entry. + Hostname *string `json:"hostname,omitempty"` + + // Message Log message text. + Message *string `json:"message,omitempty"` + + // Pid Process identifier that produced this entry. + Pid *int `json:"pid,omitempty"` + + // Priority Log priority level. + Priority *string `json:"priority,omitempty"` + + // Timestamp Log entry timestamp. + Timestamp *string `json:"timestamp,omitempty"` + + // Unit Systemd unit that produced this entry. + Unit *string `json:"unit,omitempty"` +} + +// LogResultEntry Log result for a single agent. +type LogResultEntry struct { + // Entries Log entries from this agent. + Entries *[]LogEntryInfo `json:"entries,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status LogResultEntryStatus `json:"status"` +} + +// LogResultEntryStatus The status of the operation for this host. +type LogResultEntryStatus string + +// Hostname defines model for Hostname. +type Hostname = string + +// UnitName defines model for UnitName. +type UnitName = string + +// GetNodeLogParams defines parameters for GetNodeLog. +type GetNodeLogParams struct { + // Lines Maximum number of log lines to return. + Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"` + + // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00"). + Since *string `form:"since,omitempty" json:"since,omitempty"` + + // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug"). + Priority *string `form:"priority,omitempty" json:"priority,omitempty"` +} + +// GetNodeLogUnitParams defines parameters for GetNodeLogUnit. +type GetNodeLogUnitParams struct { + // Lines Maximum number of log lines to return. + Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"` + + // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00"). + Since *string `form:"since,omitempty" json:"since,omitempty"` + + // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug"). + Priority *string `form:"priority,omitempty" json:"priority,omitempty"` +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Get system log entries + // (GET /node/{hostname}/log) + GetNodeLog(ctx echo.Context, hostname Hostname, params GetNodeLogParams) error + // Get log entries for a systemd unit + // (GET /node/{hostname}/log/unit/{name}) + GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// GetNodeLog converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeLog(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"log:read"}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetNodeLogParams + // ------------- Optional query parameter "lines" ------------- + + err = runtime.BindQueryParameter("form", true, false, "lines", ctx.QueryParams(), ¶ms.Lines) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter lines: %s", err)) + } + + // ------------- Optional query parameter "since" ------------- + + err = runtime.BindQueryParameter("form", true, false, "since", ctx.QueryParams(), ¶ms.Since) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter since: %s", err)) + } + + // ------------- Optional query parameter "priority" ------------- + + err = runtime.BindQueryParameter("form", true, false, "priority", ctx.QueryParams(), ¶ms.Priority) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter priority: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetNodeLog(ctx, hostname, params) + return err +} + +// GetNodeLogUnit converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeLogUnit(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + // ------------- Path parameter "name" ------------- + var name UnitName + + err = runtime.BindStyledParameterWithOptions("simple", "name", ctx.Param("name"), &name, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter name: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"log:read"}) + + // Parameter object where we will unmarshal all parameters from the context + var params GetNodeLogUnitParams + // ------------- Optional query parameter "lines" ------------- + + err = runtime.BindQueryParameter("form", true, false, "lines", ctx.QueryParams(), ¶ms.Lines) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter lines: %s", err)) + } + + // ------------- Optional query parameter "since" ------------- + + err = runtime.BindQueryParameter("form", true, false, "since", ctx.QueryParams(), ¶ms.Since) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter since: %s", err)) + } + + // ------------- Optional query parameter "priority" ------------- + + err = runtime.BindQueryParameter("form", true, false, "priority", ctx.QueryParams(), ¶ms.Priority) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter priority: %s", err)) + } + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetNodeLogUnit(ctx, hostname, name, params) + return err +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + + wrapper := ServerInterfaceWrapper{ + Handler: si, + } + + router.GET(baseURL+"/node/:hostname/log", wrapper.GetNodeLog) + router.GET(baseURL+"/node/:hostname/log/unit/:name", wrapper.GetNodeLogUnit) + +} + +type GetNodeLogRequestObject struct { + Hostname Hostname `json:"hostname"` + Params GetNodeLogParams +} + +type GetNodeLogResponseObject interface { + VisitGetNodeLogResponse(w http.ResponseWriter) error +} + +type GetNodeLog200JSONResponse LogCollectionResponse + +func (response GetNodeLog200JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLog401JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLog401JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLog403JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLog403JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLog500JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLog500JSONResponse) VisitGetNodeLogResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogUnitRequestObject struct { + Hostname Hostname `json:"hostname"` + Name UnitName `json:"name"` + Params GetNodeLogUnitParams +} + +type GetNodeLogUnitResponseObject interface { + VisitGetNodeLogUnitResponse(w http.ResponseWriter) error +} + +type GetNodeLogUnit200JSONResponse LogCollectionResponse + +func (response GetNodeLogUnit200JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogUnit401JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogUnit401JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogUnit403JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogUnit403JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogUnit500JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogUnit500JSONResponse) VisitGetNodeLogUnitResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +// StrictServerInterface represents all server handlers. +type StrictServerInterface interface { + // Get system log entries + // (GET /node/{hostname}/log) + GetNodeLog(ctx context.Context, request GetNodeLogRequestObject) (GetNodeLogResponseObject, error) + // Get log entries for a systemd unit + // (GET /node/{hostname}/log/unit/{name}) + GetNodeLogUnit(ctx context.Context, request GetNodeLogUnitRequestObject) (GetNodeLogUnitResponseObject, error) +} + +type StrictHandlerFunc = strictecho.StrictEchoHandlerFunc +type StrictMiddlewareFunc = strictecho.StrictEchoMiddlewareFunc + +func NewStrictHandler(ssi StrictServerInterface, middlewares []StrictMiddlewareFunc) ServerInterface { + return &strictHandler{ssi: ssi, middlewares: middlewares} +} + +type strictHandler struct { + ssi StrictServerInterface + middlewares []StrictMiddlewareFunc +} + +// GetNodeLog operation middleware +func (sh *strictHandler) GetNodeLog(ctx echo.Context, hostname Hostname, params GetNodeLogParams) error { + var request GetNodeLogRequestObject + + request.Hostname = hostname + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetNodeLog(ctx.Request().Context(), request.(GetNodeLogRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNodeLog") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetNodeLogResponseObject); ok { + return validResponse.VisitGetNodeLogResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + +// GetNodeLogUnit operation middleware +func (sh *strictHandler) GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error { + var request GetNodeLogUnitRequestObject + + request.Hostname = hostname + request.Name = name + request.Params = params + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetNodeLogUnit(ctx.Request().Context(), request.(GetNodeLogUnitRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNodeLogUnit") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetNodeLogUnitResponseObject); ok { + return validResponse.VisitGetNodeLogUnitResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go index 7087276b5..811bb1c53 100644 --- a/pkg/sdk/client/gen/client.gen.go +++ b/pkg/sdk/client/gen/client.gen.go @@ -202,6 +202,13 @@ const ( LoadResultItemStatusSkipped LoadResultItemStatus = "skipped" ) +// Defines values for LogResultEntryStatus. +const ( + LogResultEntryStatusFailed LogResultEntryStatus = "failed" + LogResultEntryStatusOk LogResultEntryStatus = "ok" + LogResultEntryStatusSkipped LogResultEntryStatus = "skipped" +) + // Defines values for MemoryResultItemStatus. const ( MemoryResultItemStatusFailed MemoryResultItemStatus = "failed" @@ -374,11 +381,11 @@ const ( // Defines values for GetJobParamsStatus. const ( - GetJobParamsStatusCompleted GetJobParamsStatus = "completed" - GetJobParamsStatusFailed GetJobParamsStatus = "failed" - GetJobParamsStatusPartialFailure GetJobParamsStatus = "partial_failure" - GetJobParamsStatusProcessing GetJobParamsStatus = "processing" - GetJobParamsStatusSubmitted GetJobParamsStatus = "submitted" + Completed GetJobParamsStatus = "completed" + Failed GetJobParamsStatus = "failed" + PartialFailure GetJobParamsStatus = "partial_failure" + Processing GetJobParamsStatus = "processing" + Submitted GetJobParamsStatus = "submitted" ) // Defines values for GetNodeContainerDockerParamsState. @@ -1728,6 +1735,52 @@ type LoadResultItem struct { // LoadResultItemStatus The status of the operation for this host. type LoadResultItemStatus string +// LogCollectionResponse defines model for LogCollectionResponse. +type LogCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []LogResultEntry `json:"results"` +} + +// LogEntryInfo A single log entry from the system journal. +type LogEntryInfo struct { + // Hostname Hostname of the system that produced this entry. + Hostname *string `json:"hostname,omitempty"` + + // Message Log message text. + Message *string `json:"message,omitempty"` + + // Pid Process identifier that produced this entry. + Pid *int `json:"pid,omitempty"` + + // Priority Log priority level. + Priority *string `json:"priority,omitempty"` + + // Timestamp Log entry timestamp. + Timestamp *string `json:"timestamp,omitempty"` + + // Unit Systemd unit that produced this entry. + Unit *string `json:"unit,omitempty"` +} + +// LogResultEntry Log result for a single agent. +type LogResultEntry struct { + // Entries Log entries from this agent. + Entries *[]LogEntryInfo `json:"entries,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Status The status of the operation for this host. + Status LogResultEntryStatus `json:"status"` +} + +// LogResultEntryStatus The status of the operation for this host. +type LogResultEntryStatus string + // MemoryCollectionResponse defines model for MemoryCollectionResponse. type MemoryCollectionResponse struct { // JobId The job ID used to process this request. @@ -2722,6 +2775,9 @@ type Pid = int // SysctlKey defines model for SysctlKey. type SysctlKey = string +// UnitName defines model for UnitName. +type UnitName = string + // UserName defines model for UserName. type UserName = string @@ -2793,6 +2849,30 @@ type DeleteNodeContainerDockerByIDParams struct { Force *bool `form:"force,omitempty" json:"force,omitempty" validate:"omitempty"` } +// GetNodeLogParams defines parameters for GetNodeLog. +type GetNodeLogParams struct { + // Lines Maximum number of log lines to return. + Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"` + + // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00"). + Since *string `form:"since,omitempty" json:"since,omitempty"` + + // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug"). + Priority *string `form:"priority,omitempty" json:"priority,omitempty"` +} + +// GetNodeLogUnitParams defines parameters for GetNodeLogUnit. +type GetNodeLogUnitParams struct { + // Lines Maximum number of log lines to return. + Lines *int `form:"lines,omitempty" json:"lines,omitempty" validate:"omitempty,min=1,max=10000"` + + // Since Return log entries since this time. Accepts systemd time specifications (e.g., "1h", "2026-01-01 00:00:00"). + Since *string `form:"since,omitempty" json:"since,omitempty"` + + // Priority Filter by log priority level (e.g., "err", "warning", "info", "debug"). + Priority *string `form:"priority,omitempty" json:"priority,omitempty"` +} + // PostNodeNetworkPingJSONBody defines parameters for PostNodeNetworkPing. type PostNodeNetworkPingJSONBody struct { // Address The IP address of the server to ping. Supports both IPv4 and IPv6. Also accepts @fact. references that are resolved agent-side. @@ -3117,6 +3197,12 @@ type ClientInterface interface { // GetNodeLoad request GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeLog request + GetNodeLog(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeLogUnit request + GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeMemory request GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3942,6 +4028,30 @@ func (c *Client) GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors return c.Client.Do(req) } +func (c *Client) GetNodeLog(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeLogRequest(c.Server, hostname, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeLogUnitRequest(c.Server, hostname, name, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetNodeMemoryRequest(c.Server, hostname) if err != nil { @@ -6436,6 +6546,189 @@ func NewGetNodeLoadRequest(server string, hostname Hostname) (*http.Request, err return req, nil } +// NewGetNodeLogRequest generates requests for GetNodeLog +func NewGetNodeLogRequest(server string, hostname Hostname, params *GetNodeLogParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/log", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Lines != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "lines", runtime.ParamLocationQuery, *params.Lines); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Since != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "since", runtime.ParamLocationQuery, *params.Since); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Priority != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "priority", runtime.ParamLocationQuery, *params.Priority); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeLogUnitRequest generates requests for GetNodeLogUnit +func NewGetNodeLogUnitRequest(server string, hostname Hostname, name UnitName, params *GetNodeLogUnitParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/log/unit/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Lines != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "lines", runtime.ParamLocationQuery, *params.Lines); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Since != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "since", runtime.ParamLocationQuery, *params.Since); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Priority != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "priority", runtime.ParamLocationQuery, *params.Priority); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetNodeMemoryRequest generates requests for GetNodeMemory func NewGetNodeMemoryRequest(server string, hostname Hostname) (*http.Request, error) { var err error @@ -8300,6 +8593,12 @@ type ClientWithResponsesInterface interface { // GetNodeLoadWithResponse request GetNodeLoadWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLoadResponse, error) + // GetNodeLogWithResponse request + GetNodeLogWithResponse(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*GetNodeLogResponse, error) + + // GetNodeLogUnitWithResponse request + GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error) + // GetNodeMemoryWithResponse request GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error) @@ -9576,6 +9875,56 @@ func (r GetNodeLoadResponse) StatusCode() int { return 0 } +type GetNodeLogResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *LogCollectionResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeLogResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeLogResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeLogUnitResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *LogCollectionResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeLogUnitResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeLogUnitResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetNodeMemoryResponse struct { Body []byte HTTPResponse *http.Response @@ -11100,6 +11449,24 @@ func (c *ClientWithResponses) GetNodeLoadWithResponse(ctx context.Context, hostn return ParseGetNodeLoadResponse(rsp) } +// GetNodeLogWithResponse request returning *GetNodeLogResponse +func (c *ClientWithResponses) GetNodeLogWithResponse(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*GetNodeLogResponse, error) { + rsp, err := c.GetNodeLog(ctx, hostname, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeLogResponse(rsp) +} + +// GetNodeLogUnitWithResponse request returning *GetNodeLogUnitResponse +func (c *ClientWithResponses) GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error) { + rsp, err := c.GetNodeLogUnit(ctx, hostname, name, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeLogUnitResponse(rsp) +} + // GetNodeMemoryWithResponse request returning *GetNodeMemoryResponse func (c *ClientWithResponses) GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error) { rsp, err := c.GetNodeMemory(ctx, hostname, reqEditors...) @@ -13921,6 +14288,100 @@ func ParseGetNodeLoadResponse(rsp *http.Response) (*GetNodeLoadResponse, error) return response, nil } +// ParseGetNodeLogResponse parses an HTTP response from a GetNodeLogWithResponse call +func ParseGetNodeLogResponse(rsp *http.Response) (*GetNodeLogResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeLogResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest LogCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeLogUnitResponse parses an HTTP response from a GetNodeLogUnitWithResponse call +func ParseGetNodeLogUnitResponse(rsp *http.Response) (*GetNodeLogUnitResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeLogUnitResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest LogCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetNodeMemoryResponse parses an HTTP response from a GetNodeMemoryWithResponse call func ParseGetNodeMemoryResponse(rsp *http.Response) (*GetNodeMemoryResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) From 78822bf7ff6f2f85cf349888b60fc85ca1e1f321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:31:06 -0700 Subject: [PATCH 07/19] feat(log): add API handlers with broadcast support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 🤖 Generated with [Claude Code](https://claude.ai/code) --- cmd/controller_setup.go | 2 + internal/controller/api/node/log/handler.go | 66 +++ .../api/node/log/handler_public_test.go | 96 ++++ internal/controller/api/node/log/log.go | 43 ++ .../controller/api/node/log/log_query_get.go | 204 ++++++++ .../api/node/log/log_query_get_public_test.go | 492 ++++++++++++++++++ .../controller/api/node/log/log_unit_get.go | 181 +++++++ .../api/node/log/log_unit_get_public_test.go | 485 +++++++++++++++++ internal/controller/api/node/log/types.go | 34 ++ internal/controller/api/node/log/validate.go | 34 ++ 10 files changed, 1637 insertions(+) create mode 100644 internal/controller/api/node/log/handler.go create mode 100644 internal/controller/api/node/log/handler_public_test.go create mode 100644 internal/controller/api/node/log/log.go create mode 100644 internal/controller/api/node/log/log_query_get.go create mode 100644 internal/controller/api/node/log/log_query_get_public_test.go create mode 100644 internal/controller/api/node/log/log_unit_get.go create mode 100644 internal/controller/api/node/log/log_unit_get_public_test.go create mode 100644 internal/controller/api/node/log/types.go create mode 100644 internal/controller/api/node/log/validate.go diff --git a/cmd/controller_setup.go b/cmd/controller_setup.go index 75089f2c4..3c6fd666d 100644 --- a/cmd/controller_setup.go +++ b/cmd/controller_setup.go @@ -54,6 +54,7 @@ import ( ntpAPI "github.com/retr0h/osapi/internal/controller/api/node/ntp" packageAPI "github.com/retr0h/osapi/internal/controller/api/node/package" powerAPI "github.com/retr0h/osapi/internal/controller/api/node/power" + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" processAPI "github.com/retr0h/osapi/internal/controller/api/node/process" scheduleAPI "github.com/retr0h/osapi/internal/controller/api/node/schedule" sysctlAPI "github.com/retr0h/osapi/internal/controller/api/node/sysctl" @@ -732,6 +733,7 @@ func registerControllerHandlers( handlers = append(handlers, processAPI.Handler(log, jc, signingKey, customRoles)...) handlers = append(handlers, userAPI.Handler(log, jc, signingKey, customRoles)...) handlers = append(handlers, packageAPI.Handler(log, jc, signingKey, customRoles)...) + handlers = append(handlers, logAPI.Handler(log, jc, signingKey, customRoles)...) handlers = append(handlers, nodeFileAPI.Handler(log, jc, signingKey, customRoles)...) if auditStore != nil { handlers = append(handlers, auditAPI.Handler(log, auditStore, signingKey, customRoles)...) diff --git a/internal/controller/api/node/log/handler.go b/internal/controller/api/node/log/handler.go new file mode 100644 index 000000000..58bdd4fe8 --- /dev/null +++ b/internal/controller/api/node/log/handler.go @@ -0,0 +1,66 @@ +// 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 log + +import ( + "log/slog" + + "github.com/labstack/echo/v4" + strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/controller/api" + gen "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job/client" +) + +// Handler returns Log route registration functions. +func Handler( + logger *slog.Logger, + jobClient client.JobClient, + signingKey string, + customRoles map[string][]string, +) []func(e *echo.Echo) { + var tokenManager api.TokenValidator = authtoken.New(logger) + + logHandler := New(logger, jobClient) + + strictHandler := gen.NewStrictHandler( + logHandler, + []gen.StrictMiddlewareFunc{ + func(handler strictecho.StrictEchoHandlerFunc, _ string) strictecho.StrictEchoHandlerFunc { + return api.ScopeMiddleware( + handler, + tokenManager, + signingKey, + gen.BearerAuthScopes, + customRoles, + ) + }, + }, + ) + + return []func(e *echo.Echo){ + func(e *echo.Echo) { + gen.RegisterHandlers(e, strictHandler) + }, + } +} diff --git a/internal/controller/api/node/log/handler_public_test.go b/internal/controller/api/node/log/handler_public_test.go new file mode 100644 index 000000000..f284558d3 --- /dev/null +++ b/internal/controller/api/node/log/handler_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 log_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/suite" + + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" + "github.com/retr0h/osapi/internal/job/mocks" +) + +type LogHandlerPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *mocks.MockJobClient +} + +func (s *LogHandlerPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = mocks.NewMockJobClient(s.mockCtrl) +} + +func (s *LogHandlerPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *LogHandlerPublicTestSuite) TestHandler() { + tests := []struct { + name string + validate func([]func(e *echo.Echo)) + }{ + { + name: "returns handler functions", + validate: func(handlers []func(e *echo.Echo)) { + s.NotEmpty(handlers) + }, + }, + { + name: "closure registers routes and middleware executes", + validate: func(handlers []func(e *echo.Echo)) { + e := echo.New() + for _, h := range handlers { + h(e) + } + s.NotEmpty(e.Routes()) + + req := httptest.NewRequest(http.MethodGet, "/node/hostname/log", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + handlers := logAPI.Handler( + slog.Default(), + s.mockJobClient, + "test-signing-key", + nil, + ) + + tt.validate(handlers) + }) + } +} + +func TestLogHandlerPublicTestSuite(t *testing.T) { + suite.Run(t, new(LogHandlerPublicTestSuite)) +} diff --git a/internal/controller/api/node/log/log.go b/internal/controller/api/node/log/log.go new file mode 100644 index 000000000..b04b31b84 --- /dev/null +++ b/internal/controller/api/node/log/log.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// Package log provides log management API handlers. +package log + +import ( + "log/slog" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job/client" +) + +// ensure that we've conformed to the `StrictServerInterface` with a compile-time check +var _ gen.StrictServerInterface = (*Log)(nil) + +// New factory to create a new instance. +func New( + logger *slog.Logger, + jobClient client.JobClient, +) *Log { + return &Log{ + JobClient: jobClient, + logger: logger.With(slog.String("subsystem", "api.log")), + } +} diff --git a/internal/controller/api/node/log/log_query_get.go b/internal/controller/api/node/log/log_query_get.go new file mode 100644 index 000000000..f6c7838a8 --- /dev/null +++ b/internal/controller/api/node/log/log_query_get.go @@ -0,0 +1,204 @@ +// 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 log + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +// GetNodeLog returns system log entries from a target node. +func (s *Log) GetNodeLog( + ctx context.Context, + request gen.GetNodeLogRequestObject, +) (gen.GetNodeLogResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + + s.logger.Debug("log query", + slog.String("target", hostname), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + opts := logProv.QueryOpts{} + if request.Params.Lines != nil { + opts.Lines = *request.Params.Lines + } + if request.Params.Since != nil { + opts.Since = *request.Params.Since + } + if request.Params.Priority != nil { + opts.Priority = *request.Params.Priority + } + + data, err := json.Marshal(opts) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + if job.IsBroadcastTarget(hostname) { + return s.getNodeLogBroadcast(ctx, hostname, data) + } + + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, data) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.Skipped, + Error: &e, + }, + }, + }, nil + } + + entries := logEntriesFromResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.Ok, + Entries: &entries, + }, + }, + }, nil +} + +// logEntriesFromResponse extracts LogEntryInfo slice from a job response. +func logEntriesFromResponse( + resp *job.Response, +) []gen.LogEntryInfo { + var entries []logProv.Entry + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &entries) + } + + result := make([]gen.LogEntryInfo, 0, len(entries)) + for _, e := range entries { + result = append(result, logEntryToGen(e)) + } + + return result +} + +// logEntryToGen converts a provider log.Entry to a gen.LogEntryInfo. +func logEntryToGen( + e logProv.Entry, +) gen.LogEntryInfo { + return gen.LogEntryInfo{ + Timestamp: stringPtrOrNil(e.Timestamp), + Unit: stringPtrOrNil(e.Unit), + Priority: stringPtrOrNil(e.Priority), + Message: stringPtrOrNil(e.Message), + Pid: intPtrOrNil(e.PID), + Hostname: stringPtrOrNil(e.Hostname), + } +} + +// stringPtrOrNil returns nil if the string is empty, otherwise a pointer. +func stringPtrOrNil( + s string, +) *string { + if s == "" { + return nil + } + return &s +} + +// intPtrOrNil returns nil if the int is zero, otherwise a pointer. +func intPtrOrNil( + i int, +) *int { + if i == 0 { + return nil + } + return &i +} + +// getNodeLogBroadcast handles broadcast targets for log query. +func (s *Log) getNodeLogBroadcast( + ctx context.Context, + target string, + data []byte, +) (gen.GetNodeLogResponseObject, error) { + jobID, responses, err := s.JobClient.QueryBroadcast( + ctx, + target, + "node", + job.OperationLogQuery, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil + } + + var items []gen.LogResultEntry + for host, resp := range responses { + item := gen.LogResultEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.Failed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.Skipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.Ok + entries := logEntriesFromResponse(resp) + item.Entries = &entries + } + items = append(items, item) + } + + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLog200JSONResponse{ + JobId: &jobUUID, + Results: items, + }, nil +} diff --git a/internal/controller/api/node/log/log_query_get_public_test.go b/internal/controller/api/node/log/log_query_get_public_test.go new file mode 100644 index 000000000..3cdf45374 --- /dev/null +++ b/internal/controller/api/node/log/log_query_get_public_test.go @@ -0,0 +1,492 @@ +// 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 log_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type LogQueryPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *logAPI.Log + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *LogQueryPublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *LogQueryPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = logAPI.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *LogQueryPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *LogQueryPublicTestSuite) TestGetNodeLog() { + tests := []struct { + name string + request gen.GetNodeLogRequestObject + setupMock func() + validateFunc func(resp gen.GetNodeLogResponseObject) + }{ + { + name: "success", + request: gen.GetNodeLogRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage( + `[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Started OpenSSH server","pid":1234,"hostname":"agent1"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("agent1", r.Results[0].Hostname) + s.Equal(gen.Ok, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Entries) + s.Len(*r.Results[0].Entries, 1) + e := (*r.Results[0].Entries)[0] + s.Equal("2026-01-01T00:00:00Z", *e.Timestamp) + s.Equal("Started OpenSSH server", *e.Message) + }, + }, + { + name: "validation error empty hostname", + request: gen.GetNodeLogRequestObject{ + Hostname: "", + }, + setupMock: func() {}, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "success with nil response data", + request: gen.GetNodeLogRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: nil, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("agent1", r.Results[0].Hostname) + s.Require().NotNil(r.Results[0].Entries) + s.Empty(*r.Results[0].Entries) + }, + }, + { + name: "job client error", + request: gen.GetNodeLogRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + _, ok := resp.(gen.GetNodeLog500JSONResponse) + s.True(ok) + }, + }, + { + name: "when job skipped", + request: gen.GetNodeLogRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.Skipped, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Error) + s.Equal("unsupported", *r.Results[0].Error) + }, + }, + { + name: "broadcast success", + request: gen.GetNodeLogRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server1", + Data: json.RawMessage( + `[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"hello"}]`, + ), + }, + "server2": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server2", + Data: json.RawMessage(`[]`), + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Len(r.Results, 2) + }, + }, + { + name: "broadcast with failed and skipped hosts", + request: gen.GetNodeLogRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + Hostname: "server1", + Data: json.RawMessage(`[]`), + }, + "server2": { + Status: job.StatusFailed, + Error: "agent unreachable", + Hostname: "server2", + }, + "server3": { + Status: job.StatusSkipped, + Error: "log: operation not supported on this OS family", + Hostname: "server3", + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + }, + }, + { + name: "broadcast error collecting responses", + request: gen.GetNodeLogRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + _, ok := resp.(gen.GetNodeLog500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + resp, err := s.handler.GetNodeLog(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *LogQueryPublicTestSuite) TestGetNodeLogHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/log", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogQuery, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/log", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + logHandler := logAPI.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(logHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacLogQueryTestSigningKey = "test-signing-key-for-rbac-log-query" + +func (s *LogQueryPublicTestSuite) TestGetNodeLogRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogQueryTestSigningKey, + []string{"write"}, + "test-user", + []string{"docker:write"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with log:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogQueryTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogQuery, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{ + SigningKey: rbacLogQueryTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := logAPI.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/node/server1/log", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +func TestLogQueryPublicTestSuite(t *testing.T) { + suite.Run(t, new(LogQueryPublicTestSuite)) +} diff --git a/internal/controller/api/node/log/log_unit_get.go b/internal/controller/api/node/log/log_unit_get.go new file mode 100644 index 000000000..ac07a651f --- /dev/null +++ b/internal/controller/api/node/log/log_unit_get.go @@ -0,0 +1,181 @@ +// 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 log + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + logProv "github.com/retr0h/osapi/internal/provider/node/log" +) + +// unitQueryPayload is the JSON payload sent to the agent for unit log queries. +type unitQueryPayload struct { + Unit string `json:"unit"` + Lines int `json:"lines,omitempty"` + Since string `json:"since,omitempty"` + Priority string `json:"priority,omitempty"` +} + +// GetNodeLogUnit returns log entries for a specific systemd unit from a target node. +func (s *Log) GetNodeLogUnit( + ctx context.Context, + request gen.GetNodeLogUnitRequestObject, +) (gen.GetNodeLogUnitResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + + s.logger.Debug("log unit query", + slog.String("target", hostname), + slog.String("unit", request.Name), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + payload := unitQueryPayload{ + Unit: request.Name, + } + if request.Params.Lines != nil { + payload.Lines = *request.Params.Lines + } + if request.Params.Since != nil { + payload.Since = *request.Params.Since + } + if request.Params.Priority != nil { + payload.Priority = *request.Params.Priority + } + + data, err := json.Marshal(payload) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil + } + + if job.IsBroadcastTarget(hostname) { + return s.getNodeLogUnitBroadcast(ctx, hostname, data) + } + + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQueryUnit, data) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLogUnit200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.Skipped, + Error: &e, + }, + }, + }, nil + } + + entries := logEntriesFromUnitResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLogUnit200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogResultEntry{ + { + Hostname: resp.Hostname, + Status: gen.Ok, + Entries: &entries, + }, + }, + }, nil +} + +// logEntriesFromUnitResponse extracts LogEntryInfo slice from a unit job response. +func logEntriesFromUnitResponse( + resp *job.Response, +) []gen.LogEntryInfo { + var entries []logProv.Entry + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &entries) + } + + result := make([]gen.LogEntryInfo, 0, len(entries)) + for _, e := range entries { + result = append(result, logEntryToGen(e)) + } + + return result +} + +// getNodeLogUnitBroadcast handles broadcast targets for unit log query. +func (s *Log) getNodeLogUnitBroadcast( + ctx context.Context, + target string, + data []byte, +) (gen.GetNodeLogUnitResponseObject, error) { + jobID, responses, err := s.JobClient.QueryBroadcast( + ctx, + target, + "node", + job.OperationLogQueryUnit, + data, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil + } + + var items []gen.LogResultEntry + for host, resp := range responses { + item := gen.LogResultEntry{ + Hostname: host, + } + switch resp.Status { + case job.StatusFailed: + item.Status = gen.Failed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.Skipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.Ok + entries := logEntriesFromUnitResponse(resp) + item.Entries = &entries + } + items = append(items, item) + } + + jobUUID := uuid.MustParse(jobID) + return gen.GetNodeLogUnit200JSONResponse{ + JobId: &jobUUID, + Results: items, + }, nil +} diff --git a/internal/controller/api/node/log/log_unit_get_public_test.go b/internal/controller/api/node/log/log_unit_get_public_test.go new file mode 100644 index 000000000..76d416006 --- /dev/null +++ b/internal/controller/api/node/log/log_unit_get_public_test.go @@ -0,0 +1,485 @@ +// 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 log_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" +) + +type LogUnitPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *logAPI.Log + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *LogUnitPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = logAPI.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.Default() +} + +func (s *LogUnitPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *LogUnitPublicTestSuite) TestGetNodeLogUnit() { + tests := []struct { + name string + request gen.GetNodeLogUnitRequestObject + setupMock func() + validateFunc func(resp gen.GetNodeLogUnitResponseObject) + }{ + { + name: "success", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "server1", + Name: "sshd.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage( + `[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Started OpenSSH server","pid":1234,"hostname":"agent1"}]`, + ), + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("agent1", r.Results[0].Hostname) + s.Equal(gen.Ok, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Entries) + s.Len(*r.Results[0].Entries, 1) + e := (*r.Results[0].Entries)[0] + s.Equal("sshd.service", *e.Unit) + }, + }, + { + name: "validation error empty hostname", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "", + Name: "sshd.service", + }, + setupMock: func() {}, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "success with nil response data", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "server1", + Name: "sshd.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: nil, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Require().NotNil(r.Results[0].Entries) + s.Empty(*r.Results[0].Entries) + }, + }, + { + name: "job client error", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "server1", + Name: "sshd.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + _, ok := resp.(gen.GetNodeLogUnit500JSONResponse) + s.True(ok) + }, + }, + { + name: "when job skipped", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "server1", + Name: "sshd.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.Skipped, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Error) + s.Equal("unsupported", *r.Results[0].Error) + }, + }, + { + name: "broadcast success", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "_all", + Name: "nginx.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server1", + Data: json.RawMessage(`[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"nginx started"}]`), + }, + "server2": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server2", + Data: json.RawMessage(`[]`), + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Len(r.Results, 2) + }, + }, + { + name: "broadcast with failed and skipped hosts", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "_all", + Name: "nginx.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + Hostname: "server1", + Data: json.RawMessage(`[]`), + }, + "server2": { + Status: job.StatusFailed, + Error: "agent unreachable", + Hostname: "server2", + }, + "server3": { + Status: job.StatusSkipped, + Error: "log: operation not supported on this OS family", + Hostname: "server3", + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + }, + }, + { + name: "broadcast error collecting responses", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "_all", + Name: "nginx.service", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + _, ok := resp.(gen.GetNodeLogUnit500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + resp, err := s.handler.GetNodeLogUnit(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *LogUnitPublicTestSuite) TestGetNodeLogUnitHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/log/unit/sshd.service", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogQueryUnit, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/log/unit/sshd.service", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + logHandler := logAPI.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(logHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacLogUnitTestSigningKey = "test-signing-key-for-rbac-log-unit" + +func (s *LogUnitPublicTestSuite) TestGetNodeLogUnitRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogUnitTestSigningKey, + []string{"write"}, + "test-user", + []string{"docker:write"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with log:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogUnitTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogQueryUnit, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{ + SigningKey: rbacLogUnitTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := logAPI.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/node/server1/log/unit/sshd.service", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +func TestLogUnitPublicTestSuite(t *testing.T) { + suite.Run(t, new(LogUnitPublicTestSuite)) +} diff --git a/internal/controller/api/node/log/types.go b/internal/controller/api/node/log/types.go new file mode 100644 index 000000000..56d730d27 --- /dev/null +++ b/internal/controller/api/node/log/types.go @@ -0,0 +1,34 @@ +// 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 log + +import ( + "log/slog" + + "github.com/retr0h/osapi/internal/job/client" +) + +// Log implementation of the Log APIs operations. +type Log struct { + // JobClient provides job-based operations for log management. + JobClient client.JobClient + logger *slog.Logger +} diff --git a/internal/controller/api/node/log/validate.go b/internal/controller/api/node/log/validate.go new file mode 100644 index 000000000..2be520527 --- /dev/null +++ b/internal/controller/api/node/log/validate.go @@ -0,0 +1,34 @@ +// 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 log + +import "github.com/retr0h/osapi/internal/validation" + +// validateHostname validates a hostname path parameter using the shared +// validator. Returns the error message and false if invalid. +// +// This exists because oapi-codegen does not generate validate tags on +// path parameters in strict-server mode (upstream limitation). +func validateHostname( + hostname string, +) (string, bool) { + return validation.Var(hostname, "required,min=1,valid_target") +} From c8bd64540d1bc12a5bcad00a0805ceaca4ffa2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:36:08 -0700 Subject: [PATCH 08/19] feat(log): add SDK service with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add LogService with Query and QueryUnit methods wrapping the generated OpenAPI client. Includes result types, gen→SDK conversions, export bridges, and full httptest.Server test coverage for all HTTP paths. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- pkg/sdk/client/export_test.go | 14 + pkg/sdk/client/log.go | 107 ++++++ pkg/sdk/client/log_public_test.go | 426 ++++++++++++++++++++++++ pkg/sdk/client/log_types.go | 104 ++++++ pkg/sdk/client/log_types_public_test.go | 236 +++++++++++++ pkg/sdk/client/osapi.go | 4 + 6 files changed, 891 insertions(+) create mode 100644 pkg/sdk/client/log.go create mode 100644 pkg/sdk/client/log_public_test.go create mode 100644 pkg/sdk/client/log_types.go create mode 100644 pkg/sdk/client/log_types_public_test.go diff --git a/pkg/sdk/client/export_test.go b/pkg/sdk/client/export_test.go index 20608ae61..f6ea86029 100644 --- a/pkg/sdk/client/export_test.go +++ b/pkg/sdk/client/export_test.go @@ -653,3 +653,17 @@ func ExportUpdateInfosFromGen( ) []UpdateInfo { return updateInfosFromGen(input) } + +// LogCollectionFromGen exposes the private logCollectionFromGen for testing. +func LogCollectionFromGen( + input *gen.LogCollectionResponse, +) Collection[LogEntryResult] { + return logCollectionFromGen(input) +} + +// LogEntryInfoFromGen exposes the private logEntryInfoFromGen for testing. +func LogEntryInfoFromGen( + input gen.LogEntryInfo, +) LogEntry { + return logEntryInfoFromGen(input) +} diff --git a/pkg/sdk/client/log.go b/pkg/sdk/client/log.go new file mode 100644 index 000000000..c5e858113 --- /dev/null +++ b/pkg/sdk/client/log.go @@ -0,0 +1,107 @@ +// 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 client + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +// LogService provides log viewing operations. +type LogService struct { + client *gen.ClientWithResponses +} + +// Query returns journal log entries for the target host. +func (s *LogService) Query( + ctx context.Context, + hostname string, + opts LogQueryOpts, +) (*Response[Collection[LogEntryResult]], error) { + params := &gen.GetNodeLogParams{ + Lines: opts.Lines, + Since: opts.Since, + Priority: opts.Priority, + } + + resp, err := s.client.GetNodeLogWithResponse(ctx, hostname, params) + if err != nil { + return nil, fmt.Errorf("log query: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// QueryUnit returns journal log entries for a specific systemd unit on the +// target host. +func (s *LogService) QueryUnit( + ctx context.Context, + hostname string, + unit string, + opts LogQueryOpts, +) (*Response[Collection[LogEntryResult]], error) { + params := &gen.GetNodeLogUnitParams{ + Lines: opts.Lines, + Since: opts.Since, + Priority: opts.Priority, + } + + resp, err := s.client.GetNodeLogUnitWithResponse(ctx, hostname, unit, params) + if err != nil { + return nil, fmt.Errorf("log query unit: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil +} diff --git a/pkg/sdk/client/log_public_test.go b/pkg/sdk/client/log_public_test.go new file mode 100644 index 000000000..72e32e1fa --- /dev/null +++ b/pkg/sdk/client/log_public_test.go @@ -0,0 +1,426 @@ +// 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 client_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" +) + +type LogPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *LogPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *LogPublicTestSuite) TestQuery() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + opts client.LogQueryOpts + validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error) + }{ + { + name: "when querying logs returns result collection", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[{"timestamp":"2026-01-01T00:00:00Z","unit":"sshd.service","priority":"info","message":"Accepted publickey","pid":1234,"hostname":"agent1"}]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.Require().Len(resp.Data.Results[0].Entries, 1) + suite.Equal("Accepted publickey", resp.Data.Results[0].Entries[0].Message) + suite.Equal("sshd.service", resp.Data.Results[0].Entries[0].Unit) + }, + }, + { + name: "when broadcast query returns multiple results", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"server1","status":"ok","entries":[]},{"hostname":"server2","status":"ok","entries":[]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 2) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "log query") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.Log.Query(suite.ctx, "_any", tc.opts) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *LogPublicTestSuite) TestQueryWithOpts() { + lines := 100 + since := "1h" + priority := "err" + + tests := []struct { + name string + handler http.HandlerFunc + opts client.LogQueryOpts + validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error) + }{ + { + name: "when all options are set returns result", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[]}]}`, + ), + ) + }, + opts: client.LogQueryOpts{ + Lines: &lines, + Since: &since, + Priority: &priority, + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + server := httptest.NewServer(tc.handler) + defer server.Close() + + sut := client.New( + server.URL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.Log.Query(suite.ctx, "_any", tc.opts) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *LogPublicTestSuite) TestQueryUnit() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.LogEntryResult]], error) + }{ + { + name: "when querying unit logs returns result collection", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","entries":[{"timestamp":"2026-01-01T00:00:00Z","unit":"nginx.service","priority":"info","message":"Started nginx","pid":5678,"hostname":"agent1"}]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.Require().Len(resp.Data.Results[0].Entries, 1) + suite.Equal("Started nginx", resp.Data.Results[0].Entries[0].Message) + suite.Equal("nginx.service", resp.Data.Results[0].Entries[0].Unit) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "log query unit") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogEntryResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.Log.QueryUnit(suite.ctx, "_any", "nginx.service", client.LogQueryOpts{}) + tc.validateFunc(resp, err) + }) + } +} + +func TestLogPublicTestSuite(t *testing.T) { + suite.Run(t, new(LogPublicTestSuite)) +} diff --git a/pkg/sdk/client/log_types.go b/pkg/sdk/client/log_types.go new file mode 100644 index 000000000..2aade0c8b --- /dev/null +++ b/pkg/sdk/client/log_types.go @@ -0,0 +1,104 @@ +// 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 client + +import ( + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +// LogEntryResult represents the result of a log query for one host. +type LogEntryResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Entries []LogEntry `json:"entries,omitempty"` + Error string `json:"error,omitempty"` +} + +// LogEntry represents a single journal entry. +type LogEntry struct { + Timestamp string `json:"timestamp,omitempty"` + Unit string `json:"unit,omitempty"` + Priority string `json:"priority,omitempty"` + Message string `json:"message,omitempty"` + PID int `json:"pid,omitempty"` + Hostname string `json:"hostname,omitempty"` +} + +// LogQueryOpts contains options for log query operations. +type LogQueryOpts struct { + // Lines is the maximum number of log lines to return. + Lines *int + // Since filters entries since this time (e.g., "1h", "2026-01-01 00:00:00"). + Since *string + // Priority filters by log priority level (e.g., "err", "warning", "info"). + Priority *string +} + +// logCollectionFromGen converts a gen.LogCollectionResponse to a +// Collection[LogEntryResult]. +func logCollectionFromGen( + g *gen.LogCollectionResponse, +) Collection[LogEntryResult] { + results := make([]LogEntryResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, logEntryResultFromGen(r)) + } + + return Collection[LogEntryResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// logEntryResultFromGen converts a gen.LogResultEntry to a LogEntryResult. +func logEntryResultFromGen( + r gen.LogResultEntry, +) LogEntryResult { + result := LogEntryResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + } + + if r.Entries != nil { + entries := make([]LogEntry, 0, len(*r.Entries)) + for _, e := range *r.Entries { + entries = append(entries, logEntryInfoFromGen(e)) + } + result.Entries = entries + } + + return result +} + +// logEntryInfoFromGen converts a gen.LogEntryInfo to a LogEntry. +func logEntryInfoFromGen( + e gen.LogEntryInfo, +) LogEntry { + return LogEntry{ + Timestamp: derefString(e.Timestamp), + Unit: derefString(e.Unit), + Priority: derefString(e.Priority), + Message: derefString(e.Message), + PID: derefInt(e.Pid), + Hostname: derefString(e.Hostname), + } +} diff --git a/pkg/sdk/client/log_types_public_test.go b/pkg/sdk/client/log_types_public_test.go new file mode 100644 index 000000000..c092a3e2f --- /dev/null +++ b/pkg/sdk/client/log_types_public_test.go @@ -0,0 +1,236 @@ +// 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 client_test + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" +) + +type LogTypesPublicTestSuite struct { + suite.Suite +} + +func (suite *LogTypesPublicTestSuite) TestLogCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *gen.LogCollectionResponse + validateFunc func(client.Collection[client.LogEntryResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.LogCollectionResponse { + ts := "2026-01-01T00:00:00Z" + unit := "sshd.service" + priority := "info" + message := "Accepted publickey for root" + pid := 1234 + hostname := "web-01" + return &gen.LogCollectionResponse{ + JobId: &testUUID, + Results: []gen.LogResultEntry{ + { + Hostname: "web-01", + Status: gen.LogResultEntryStatusOk, + Entries: &[]gen.LogEntryInfo{ + { + Timestamp: &ts, + Unit: &unit, + Priority: &priority, + Message: &message, + Pid: &pid, + Hostname: &hostname, + }, + }, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.LogEntryResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("ok", r.Status) + suite.Empty(r.Error) + suite.Require().Len(r.Entries, 1) + + e := r.Entries[0] + suite.Equal("2026-01-01T00:00:00Z", e.Timestamp) + suite.Equal("sshd.service", e.Unit) + suite.Equal("info", e.Priority) + suite.Equal("Accepted publickey for root", e.Message) + suite.Equal(1234, e.PID) + suite.Equal("web-01", e.Hostname) + }, + }, + { + name: "when result has error", + input: func() *gen.LogCollectionResponse { + errMsg := "permission denied" + return &gen.LogCollectionResponse{ + Results: []gen.LogResultEntry{ + { + Hostname: "web-01", + Status: gen.LogResultEntryStatusFailed, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.LogEntryResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + + r := c.Results[0] + suite.Equal("web-01", r.Hostname) + suite.Equal("failed", r.Status) + suite.Equal("permission denied", r.Error) + suite.Nil(r.Entries) + }, + }, + { + name: "when multiple results", + input: func() *gen.LogCollectionResponse { + msg1 := "Started" + msg2 := "Stopped" + errMsg := "unsupported" + return &gen.LogCollectionResponse{ + JobId: &testUUID, + Results: []gen.LogResultEntry{ + { + Hostname: "web-01", + Status: gen.LogResultEntryStatusOk, + Entries: &[]gen.LogEntryInfo{ + {Message: &msg1}, + {Message: &msg2}, + }, + }, + { + Hostname: "web-02", + Status: gen.LogResultEntryStatusSkipped, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c client.Collection[client.LogEntryResult]) { + suite.Require().Len(c.Results, 2) + suite.Len(c.Results[0].Entries, 2) + suite.Equal("unsupported", c.Results[1].Error) + }, + }, + { + name: "when entries pointer is nil", + input: &gen.LogCollectionResponse{ + Results: []gen.LogResultEntry{ + { + Hostname: "web-01", + Status: gen.LogResultEntryStatusSkipped, + }, + }, + }, + validateFunc: func(c client.Collection[client.LogEntryResult]) { + suite.Require().Len(c.Results, 1) + suite.Nil(c.Results[0].Entries) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.LogCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *LogTypesPublicTestSuite) TestLogEntryInfoFromGen() { + tests := []struct { + name string + input gen.LogEntryInfo + validateFunc func(client.LogEntry) + }{ + { + name: "when all fields are populated", + input: func() gen.LogEntryInfo { + ts := "2026-01-01T12:00:00Z" + unit := "nginx.service" + priority := "err" + message := "connection refused" + pid := 9999 + hostname := "web-01" + return gen.LogEntryInfo{ + Timestamp: &ts, + Unit: &unit, + Priority: &priority, + Message: &message, + Pid: &pid, + Hostname: &hostname, + } + }(), + validateFunc: func(e client.LogEntry) { + suite.Equal("2026-01-01T12:00:00Z", e.Timestamp) + suite.Equal("nginx.service", e.Unit) + suite.Equal("err", e.Priority) + suite.Equal("connection refused", e.Message) + suite.Equal(9999, e.PID) + suite.Equal("web-01", e.Hostname) + }, + }, + { + name: "when all fields are nil", + input: gen.LogEntryInfo{}, + validateFunc: func(e client.LogEntry) { + suite.Empty(e.Timestamp) + suite.Empty(e.Unit) + suite.Empty(e.Priority) + suite.Empty(e.Message) + suite.Zero(e.PID) + suite.Empty(e.Hostname) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := client.LogEntryInfoFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestLogTypesPublicTestSuite(t *testing.T) { + suite.Run(t, new(LogTypesPublicTestSuite)) +} diff --git a/pkg/sdk/client/osapi.go b/pkg/sdk/client/osapi.go index 0239b562a..0076f74db 100644 --- a/pkg/sdk/client/osapi.go +++ b/pkg/sdk/client/osapi.go @@ -128,6 +128,9 @@ type Client struct { // install, remove, update, list updates). Package *PackageService + // Log provides log viewing operations (query journal entries). + Log *LogService + httpClient *gen.ClientWithResponses baseURL string logger *slog.Logger @@ -213,6 +216,7 @@ func New( c.User = &UserService{client: httpClient} c.Group = &GroupService{client: httpClient} c.Package = &PackageService{client: httpClient} + c.Log = &LogService{client: httpClient} return c } From 79188efcc121f40a134adb659863f6138702648d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:38:34 -0700 Subject: [PATCH 09/19] feat(log): add CLI commands for journal log viewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_node_log.go | 35 ++++++++++ cmd/client_node_log_query.go | 125 +++++++++++++++++++++++++++++++++ cmd/client_node_log_unit.go | 130 +++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+) create mode 100644 cmd/client_node_log.go create mode 100644 cmd/client_node_log_query.go create mode 100644 cmd/client_node_log_unit.go diff --git a/cmd/client_node_log.go b/cmd/client_node_log.go new file mode 100644 index 000000000..7c2042d1b --- /dev/null +++ b/cmd/client_node_log.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 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) +} diff --git a/cmd/client_node_log_query.go b/cmd/client_node_log_query.go new file mode 100644 index 000000000..0c7fe4a6c --- /dev/null +++ b/cmd/client_node_log_query.go @@ -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')") +} diff --git a/cmd/client_node_log_unit.go b/cmd/client_node_log_unit.go new file mode 100644 index 000000000..1fe80d022 --- /dev/null +++ b/cmd/client_node_log_unit.go @@ -0,0 +1,130 @@ +// 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" +) + +// clientNodeLogUnitCmd represents the log unit command. +var clientNodeLogUnitCmd = &cobra.Command{ + Use: "unit", + Short: "Query journal entries for a unit", + Long: `Query journal log entries for a specific systemd unit on the target node.`, + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") + unit, _ := cmd.Flags().GetString("name") + + 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.QueryUnit(ctx, host, unit, 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(clientNodeLogUnitCmd) + + clientNodeLogUnitCmd.PersistentFlags(). + String("name", "", "Systemd unit name (required)") + clientNodeLogUnitCmd.PersistentFlags(). + Int("lines", 100, "Maximum number of log lines to return") + clientNodeLogUnitCmd.PersistentFlags(). + String("since", "", "Return entries since this time (e.g., '1h', '2026-01-01 00:00:00')") + clientNodeLogUnitCmd.PersistentFlags(). + String("priority", "", "Filter by priority level (e.g., 'err', 'warning', 'info')") + + _ = clientNodeLogUnitCmd.MarkPersistentFlagRequired("name") +} From b104ff08b21b9934738d3a93ab4ce77c752bbe3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:53:28 -0700 Subject: [PATCH 10/19] docs: add log management feature docs, SDK example, and cross-references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add feature page, CLI subcommand docs, SDK doc page, and SDK example for the log management provider. Update cross-references in features table, authentication, configuration, architecture, API guidelines, Docusaurus navbar, and SDK client overview to include log:read. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../sidebar/architecture/api-guidelines.md | 2 + .../docs/sidebar/architecture/architecture.md | 2 + docs/docs/sidebar/features/authentication.md | 6 +- docs/docs/sidebar/features/features.md | 1 + docs/docs/sidebar/features/log-management.md | 109 ++++++++++++++++++ docs/docs/sidebar/sdk/client/client.md | 1 + .../docs/sidebar/sdk/client/operations/log.md | 99 ++++++++++++++++ .../sidebar/usage/cli/client/node/log/log.md | 7 ++ .../usage/cli/client/node/log/query.md | 66 +++++++++++ .../sidebar/usage/cli/client/node/log/unit.md | 68 +++++++++++ docs/docs/sidebar/usage/configuration.md | 9 +- docs/docusaurus.config.ts | 10 ++ examples/sdk/client/log.go | 106 +++++++++++++++++ 13 files changed, 479 insertions(+), 7 deletions(-) create mode 100644 docs/docs/sidebar/features/log-management.md create mode 100644 docs/docs/sidebar/sdk/client/operations/log.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/log/log.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/log/query.md create mode 100644 docs/docs/sidebar/usage/cli/client/node/log/unit.md create mode 100644 examples/sdk/client/log.go diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md index 5d239768b..da301a11c 100644 --- a/docs/docs/sidebar/architecture/api-guidelines.md +++ b/docs/docs/sidebar/architecture/api-guidelines.md @@ -65,6 +65,8 @@ Sub-resources represent distinct capabilities of the node: | `/node/{hostname}/package/{name}` | Package | | `/node/{hostname}/package/update` | Package | | `/node/{hostname}/package/updates` | Package | +| `/node/{hostname}/log` | Log | +| `/node/{hostname}/log/unit/{name}` | Log | 6. **Path Parameters Over Query Parameters** diff --git a/docs/docs/sidebar/architecture/architecture.md b/docs/docs/sidebar/architecture/architecture.md index 8f31a2bd3..4c1ef07cd 100644 --- a/docs/docs/sidebar/architecture/architecture.md +++ b/docs/docs/sidebar/architecture/architecture.md @@ -166,6 +166,8 @@ configure them — see the Features section: and group management - [Package Management](../features/package-management.md) — system package management +- [Log Management](../features/log-management.md) — systemd journal query by + host or unit - [Job System](../features/job-system.md) — async job processing and routing - [Audit Logging](../features/audit-logging.md) — API audit trail and export - [Health Checks](../features/health-checks.md) — liveness, readiness, status diff --git a/docs/docs/sidebar/features/authentication.md b/docs/docs/sidebar/features/authentication.md index d22d19034..4443513fe 100644 --- a/docs/docs/sidebar/features/authentication.md +++ b/docs/docs/sidebar/features/authentication.md @@ -62,9 +62,9 @@ Built-in roles expand to these default permissions: | Role | Permissions | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write` | -| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write` | -| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read` | +| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | +| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | +| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | ### Custom Roles diff --git a/docs/docs/sidebar/features/features.md b/docs/docs/sidebar/features/features.md index fa947c639..14e67fcee 100644 --- a/docs/docs/sidebar/features/features.md +++ b/docs/docs/sidebar/features/features.md @@ -32,5 +32,6 @@ OSAPI provides a comprehensive set of features for managing Linux systems. | 📡 | [Process Management](process-management.md) | List, inspect, and signal running processes | | 👤 | [User & Group Management](user-management.md) | Local user account and group management | | 📦 | [Package Management](package-management.md) | System package install, remove, update, and query | +| 📄 | [Log Management](log-management.md) | Query systemd journal entries by host or unit | diff --git a/docs/docs/sidebar/features/log-management.md b/docs/docs/sidebar/features/log-management.md new file mode 100644 index 000000000..dc83becd6 --- /dev/null +++ b/docs/docs/sidebar/features/log-management.md @@ -0,0 +1,109 @@ +--- +sidebar_position: 23 +--- + +# Log Management + +OSAPI provides read-only access to the systemd journal on managed hosts. +Log entries are retrieved via `journalctl` and returned as structured +JSON with timestamp, unit, priority, message, PID, and hostname fields. + +## How It Works + +The log provider queries the systemd journal on the agent host at +request time. Each request returns a live snapshot — up to the +requested number of entries matching the given filters. + +### Query + +Returns journal log entries for the target host. The agent runs +`journalctl --output=json` with optional `--lines`, `--since`, and +`--priority` flags and returns the parsed entries. + +### QueryUnit + +Returns journal log entries scoped to a specific systemd unit. The +agent adds the `-u ` flag to the journalctl invocation alongside +the same optional filters. + +## Operations + +| Operation | Description | +| --------- | ---------------------------------------------- | +| Query | Query journal entries for the host | +| QueryUnit | Query journal entries for a specific unit name | + +## CLI Usage + +```bash +# Query last 100 log entries on a host (default) +osapi client node log query --target web-01 + +# Query last 50 error entries in the past hour +osapi client node log query --target web-01 \ + --lines 50 --since 1h --priority err + +# Query journal entries for the sshd unit +osapi client node log unit --target web-01 --name sshd.service + +# Broadcast log query to all hosts +osapi client node log query --target _all --lines 20 +``` + +All commands support `--json` for raw JSON output. + +## Broadcast Support + +All log operations support broadcast targeting. Use `--target _all` to +query logs on every registered agent, or use a label selector like +`--target group:web` to target a subset. + +Responses always include per-host results: + +``` + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + web-01 + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey ... + 2026-01-01T00:00:02+00:00 info sshd.service pam_unix(sshd:ses... +``` + +Skipped and failed hosts appear with their error in the output. + +## Supported Platforms + +| OS Family | Support | +| --------- | ------- | +| Debian | Full | +| Darwin | Skipped | +| Linux | Skipped | + +On unsupported platforms, log operations return `status: skipped` +instead of failing. See +[Platform Detection](../sdk/platform/detection.md) for details on OS +family detection. + +## Container Behavior + +Log operations return `status: skipped` inside containers. `journalctl` +requires a running systemd instance which is not available in +standard container environments. + +## Permissions + +| Operation | Permission | +| ------------------ | ---------- | +| Query, QueryUnit | `log:read` | + +Log querying requires `log:read`, included in all built-in roles +(`admin`, `write`, `read`). + +## Related + +- [CLI Reference](../usage/cli/client/node/log/log.md) -- log commands +- [SDK Reference](../sdk/client/operations/log.md) -- Log service +- [Platform Detection](../sdk/platform/detection.md) -- OS family + detection +- [Configuration](../usage/configuration.md) -- full configuration + reference diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md index 8f5e77f82..3ed111a65 100644 --- a/docs/docs/sidebar/sdk/client/client.md +++ b/docs/docs/sidebar/sdk/client/client.md @@ -54,6 +54,7 @@ resp, err := client.Hostname.Get(ctx, "_any") | [Command](operations/command.md) | Command execution (exec, shell) | | [Power](operations/power.md) | Power management (reboot, shutdown) | | [Process](operations/process.md) | Process management (list, get, signal) | +| [Log](operations/log.md) | Log query (journal entries, by unit) | ### Containers & Scheduling diff --git a/docs/docs/sidebar/sdk/client/operations/log.md b/docs/docs/sidebar/sdk/client/operations/log.md new file mode 100644 index 000000000..39480fec5 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/operations/log.md @@ -0,0 +1,99 @@ +--- +sidebar_position: 4 +--- + +# Log + +The `Log` service provides methods for querying the systemd journal on +target hosts. Access via `client.Log.Query()` and +`client.Log.QueryUnit()`. + +## Methods + +| Method | Description | +| --------------------------------------- | ---------------------------------------------- | +| `Query(ctx, hostname, opts)` | Query journal entries for the host | +| `QueryUnit(ctx, hostname, unit, opts)` | Query journal entries for a specific unit | + +## Request Types + +| Type | Fields | +| -------------- | -------------------------------------------------------------------- | +| `LogQueryOpts` | `Lines` (`*int`), `Since` (`*string`), `Priority` (`*string`) | + +## Usage + +```go +import "github.com/retr0h/osapi/pkg/sdk/client" + +c := client.New("http://localhost:8080", token) + +// Query last 50 journal entries +lines := 50 +resp, err := c.Log.Query(ctx, "web-01", client.LogQueryOpts{ + Lines: &lines, +}) +for _, r := range resp.Data.Results { + for _, e := range r.Entries { + fmt.Printf("[%s] %s %s: %s\n", + e.Timestamp, e.Priority, e.Unit, e.Message) + } +} + +// Query only error entries from the past hour +since := "1h" +priority := "err" +resp, err := c.Log.Query(ctx, "web-01", client.LogQueryOpts{ + Since: &since, + Priority: &priority, +}) + +// Query entries for a specific systemd unit +resp, err := c.Log.QueryUnit(ctx, "web-01", "sshd.service", + client.LogQueryOpts{}) +for _, r := range resp.Data.Results { + fmt.Printf("%s: %d entries\n", r.Hostname, len(r.Entries)) + for _, e := range r.Entries { + fmt.Printf(" [%s] %s\n", e.Priority, e.Message) + } +} + +// Broadcast log query to all hosts +resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{}) +``` + +## Result Types + +`LogEntryResult` is returned per host in the `Collection.Results` slice: + +| Field | Type | Description | +| ---------- | ----------- | ---------------------------------- | +| `Hostname` | `string` | Target host | +| `Status` | `string` | `ok`, `skipped`, or `failed` | +| `Entries` | `[]LogEntry`| Journal entries (nil if none) | +| `Error` | `string` | Error message if the call failed | + +`LogEntry` fields: + +| Field | Type | Description | +| ----------- | -------- | ------------------------------------- | +| `Timestamp` | `string` | ISO 8601 timestamp | +| `Unit` | `string` | Systemd unit name | +| `Priority` | `string` | Log priority (e.g., `info`, `err`) | +| `Message` | `string` | Log message text | +| `PID` | `int` | Process ID that generated the entry | +| `Hostname` | `string` | Hostname from the journal entry | + +## Example + +- [`examples/sdk/client/log.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/log.go) + +## Permissions + +| Operation | Permission | +| ------------------ | ---------- | +| Query, QueryUnit | `log:read` | + +Log management is supported on the Debian OS family (Ubuntu, Debian, +Raspbian). On unsupported platforms (Darwin, generic Linux) and inside +containers, operations return `status: skipped`. diff --git a/docs/docs/sidebar/usage/cli/client/node/log/log.md b/docs/docs/sidebar/usage/cli/client/node/log/log.md new file mode 100644 index 000000000..b112abf60 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/log/log.md @@ -0,0 +1,7 @@ +--- +sidebar_position: 1 +--- + +# Log + + diff --git a/docs/docs/sidebar/usage/cli/client/node/log/query.md b/docs/docs/sidebar/usage/cli/client/node/log/query.md new file mode 100644 index 000000000..a9eb29720 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/log/query.md @@ -0,0 +1,66 @@ +# Query + +Query journal log entries on a target host: + +```bash +$ osapi client node log query --target web-01 + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ... + 2026-01-01T00:00:02+00:00 notice kernel Linux version 6.1.0 ... + 2026-01-01T00:00:03+00:00 err nginx.service connect() failed (111... +``` + +Filter by priority and time window: + +```bash +$ osapi client node log query --target web-01 \ + --lines 50 --since 1h --priority err + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:59:01+00:00 err nginx.service connect() failed (111... +``` + +When targeting all hosts: + +```bash +$ osapi client node log query --target _all --lines 5 + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + web-01 + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ... + + web-02 + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:02+00:00 notice kernel Linux version 6.1.0 ... +``` + +## JSON Output + +Use `--json` to get the full API response: + +```bash +$ osapi client node log query --target web-01 --lines 1 --json +{"results":[{"hostname":"web-01","status":"ok","entries":[{"timestamp": +"2026-01-01T00:00:01+00:00","unit":"sshd.service","priority":"info", +"message":"Accepted publickey for user from 1.2.3.4 port 22 ssh2", +"pid":1234,"hostname":"web-01"}]}],"job_id":"..."} +``` + +## Flags + +| Flag | Description | Default | +| ----------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | +| `--lines` | Maximum number of log lines to return | `100` | +| `--since` | Return entries since this time (e.g., `1h`, | | +| | `2026-01-01 00:00:00`) | | +| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | +| | `info`) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/log/unit.md b/docs/docs/sidebar/usage/cli/client/node/log/unit.md new file mode 100644 index 000000000..3c567d791 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/log/unit.md @@ -0,0 +1,68 @@ +# Unit + +Query journal log entries for a specific systemd unit on a target host: + +```bash +$ osapi client node log unit --target web-01 --name sshd.service + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ... + 2026-01-01T00:00:02+00:00 info sshd.service pam_unix(sshd:session): ... + 2026-01-01T00:00:03+00:00 info sshd.service Disconnected from user ... +``` + +Filter to recent errors only: + +```bash +$ osapi client node log unit --target web-01 --name nginx.service \ + --lines 20 --since 30m --priority err + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:31:15+00:00 err nginx.service connect() failed (111... +``` + +When targeting all hosts: + +```bash +$ osapi client node log unit --target _all --name sshd.service --lines 5 + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + web-01 + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:01+00:00 info sshd.service Accepted publickey for ... + + web-02 + TIMESTAMP PRIORITY UNIT MESSAGE + 2026-01-01T00:00:02+00:00 info sshd.service Accepted publickey for ... +``` + +## JSON Output + +Use `--json` to get the full API response: + +```bash +$ osapi client node log unit --target web-01 --name sshd.service \ + --lines 1 --json +{"results":[{"hostname":"web-01","status":"ok","entries":[{"timestamp": +"2026-01-01T00:00:01+00:00","unit":"sshd.service","priority":"info", +"message":"Accepted publickey for user from 1.2.3.4 port 22 ssh2", +"pid":1234,"hostname":"web-01"}]}],"job_id":"..."} +``` + +## Flags + +| Flag | Description | Default | +| ----------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | +| `--name` | Systemd unit name (required, e.g., `sshd.service`) | | +| `--lines` | Maximum number of log lines to return | `100` | +| `--since` | Return entries since this time (e.g., `1h`, | | +| | `2026-01-01 00:00:00`) | | +| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | +| | `info`) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index 4469d2109..c58a3fcb6 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -156,9 +156,9 @@ of permissions: | Role | Permissions | | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write` | -| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write` | -| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read` | +| `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | +| `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | +| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | ### Custom Roles @@ -260,7 +260,8 @@ controller: # cron:read, cron:write, sysctl:read, sysctl:write, # ntp:read, ntp:write, timezone:read, timezone:write, # power:execute, process:read, process:execute, - # user:read, user:write, package:read, package:write + # user:read, user:write, package:read, package:write, + # log:read # roles: # ops: # permissions: diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 4b5020021..e7b180fd3 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -188,6 +188,11 @@ const config: Config = { type: 'doc', label: 'Package Management', docId: 'sidebar/features/package-management' + }, + { + type: 'doc', + label: 'Log Management', + docId: 'sidebar/features/log-management' } ] }, @@ -317,6 +322,11 @@ const config: Config = { label: 'Process', docId: 'sidebar/sdk/client/operations/process' }, + { + type: 'doc', + label: 'Log', + docId: 'sidebar/sdk/client/operations/log' + }, { type: 'html', value: diff --git a/examples/sdk/client/log.go b/examples/sdk/client/log.go new file mode 100644 index 000000000..e23e73819 --- /dev/null +++ b/examples/sdk/client/log.go @@ -0,0 +1,106 @@ +// 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 main demonstrates log management: query journal entries and +// query entries scoped to a specific systemd unit. +// +// All responses return Collection[LogEntryResult] with per-host results +// when targeting broadcast targets (_all, _any) or a single hostname. Use +// .Data.Results to iterate over the entries. +// +// Run with: OSAPI_TOKEN="" go run log.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/client" +) + +func logExample() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + c := client.New(url, token) + ctx := context.Background() + hostname := "_any" + + // Query the last 10 journal entries from the target host. + // Returns Collection[LogEntryResult] with per-host results. + fmt.Println("=== Querying journal entries ===") + lines := 10 + queryResp, err := c.Log.Query(ctx, hostname, client.LogQueryOpts{ + Lines: &lines, + }) + if err != nil { + log.Fatalf("log query failed: %v", err) + } + + for _, r := range queryResp.Data.Results { + if r.Error != "" { + fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error) + continue + } + fmt.Printf(" %s: %d entries\n", r.Hostname, len(r.Entries)) + for _, e := range r.Entries[:min(3, len(r.Entries))] { + fmt.Printf(" [%s] %s %s: %s\n", + e.Timestamp, e.Priority, e.Unit, e.Message) + } + if len(r.Entries) > 3 { + fmt.Printf(" ... and %d more\n", len(r.Entries)-3) + } + } + + // Query entries for the sshd systemd unit. + fmt.Println("\n=== Querying sshd.service entries ===") + unitResp, err := c.Log.QueryUnit(ctx, hostname, "sshd.service", + client.LogQueryOpts{Lines: &lines}) + if err != nil { + fmt.Printf("log unit query failed (sshd may not be running): %v\n", + err) + return + } + + for _, r := range unitResp.Data.Results { + if r.Error != "" { + fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error) + continue + } + fmt.Printf(" %s: %d sshd entries\n", r.Hostname, len(r.Entries)) + for _, e := range r.Entries { + fmt.Printf(" [%s] pid=%d %s\n", + e.Priority, e.PID, e.Message) + } + } +} + +func main() { + logExample() +} From 03889af817ad327a43366c3754a09c6699261f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 19:55:14 -0700 Subject: [PATCH 11/19] test(log): add integration test Co-Authored-By: Claude Opus 4.6 (1M context) --- test/integration/log_test.go | 120 +++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 test/integration/log_test.go diff --git a/test/integration/log_test.go b/test/integration/log_test.go new file mode 100644 index 000000000..11deffdcd --- /dev/null +++ b/test/integration/log_test.go @@ -0,0 +1,120 @@ +//go:build integration + +// 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 integration_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" +) + +type LogSmokeSuite struct { + suite.Suite +} + +// TestLogQuery verifies the log query endpoint is reachable and returns +// a valid JSON response with a results array. +func (s *LogSmokeSuite) TestLogQuery() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "query endpoint responds", + args: []string{ + "client", "node", "log", "query", + "--target", "_any", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.NotNil(results) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +// TestLogUnit verifies the log unit endpoint is reachable and returns +// a valid JSON response with a results array. +func (s *LogSmokeSuite) TestLogUnit() { + tests := []struct { + name string + args []string + validateFunc func(stdout string, exitCode int) + }{ + { + name: "unit endpoint responds", + args: []string{ + "client", "node", "log", "unit", + "--target", "_any", + "--name", "sshd.service", + "--json", + }, + validateFunc: func( + stdout string, + exitCode int, + ) { + s.Require().Equal(0, exitCode) + + var result map[string]any + err := parseJSON(stdout, &result) + s.Require().NoError(err) + + results, ok := result["results"].([]any) + s.Require().True(ok) + s.NotNil(results) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + stdout, _, exitCode := runCLI(tt.args...) + tt.validateFunc(stdout, exitCode) + }) + } +} + +func TestLogSmokeSuite( + t *testing.T, +) { + suite.Run(t, new(LogSmokeSuite)) +} From 24a9daf44056fae20a5db6566653b780035dcd82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 20:03:09 -0700 Subject: [PATCH 12/19] chore(log): fix formatting and lint issues Co-Authored-By: Claude --- cmd/controller_setup.go | 2 +- internal/agent/processor.go | 2 +- internal/agent/processor_log_public_test.go | 26 ++++++++++++------- .../api/node/log/log_unit_get_public_test.go | 4 ++- .../provider/node/log/darwin_public_test.go | 6 ++++- .../provider/node/log/linux_public_test.go | 6 ++++- pkg/sdk/client/log_public_test.go | 9 +++++-- 7 files changed, 38 insertions(+), 17 deletions(-) diff --git a/cmd/controller_setup.go b/cmd/controller_setup.go index 3c6fd666d..69f445bc0 100644 --- a/cmd/controller_setup.go +++ b/cmd/controller_setup.go @@ -50,11 +50,11 @@ import ( dockerAPI "github.com/retr0h/osapi/internal/controller/api/node/docker" nodeFileAPI "github.com/retr0h/osapi/internal/controller/api/node/file" hostnameAPI "github.com/retr0h/osapi/internal/controller/api/node/hostname" + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" networkAPI "github.com/retr0h/osapi/internal/controller/api/node/network" ntpAPI "github.com/retr0h/osapi/internal/controller/api/node/ntp" packageAPI "github.com/retr0h/osapi/internal/controller/api/node/package" powerAPI "github.com/retr0h/osapi/internal/controller/api/node/power" - logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" processAPI "github.com/retr0h/osapi/internal/controller/api/node/process" scheduleAPI "github.com/retr0h/osapi/internal/controller/api/node/schedule" sysctlAPI "github.com/retr0h/osapi/internal/controller/api/node/sysctl" diff --git a/internal/agent/processor.go b/internal/agent/processor.go index 1376bab21..66e9ca749 100644 --- a/internal/agent/processor.go +++ b/internal/agent/processor.go @@ -32,10 +32,10 @@ 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" "github.com/retr0h/osapi/internal/provider/node/ntp" "github.com/retr0h/osapi/internal/provider/node/power" - logProv "github.com/retr0h/osapi/internal/provider/node/log" processProv "github.com/retr0h/osapi/internal/provider/node/process" "github.com/retr0h/osapi/internal/provider/node/sysctl" "github.com/retr0h/osapi/internal/provider/node/timezone" diff --git a/internal/agent/processor_log_public_test.go b/internal/agent/processor_log_public_test.go index 64c9984b3..89d588517 100644 --- a/internal/agent/processor_log_public_test.go +++ b/internal/agent/processor_log_public_test.go @@ -216,7 +216,9 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQuery() { }, setupMock: func() log.Provider { m := logMocks.NewMockProvider(s.mockCtrl) - m.EXPECT().Query(gomock.Any(), gomock.Any()).Return(nil, errors.New("journalctl failed")) + m.EXPECT(). + Query(gomock.Any(), gomock.Any()). + Return(nil, errors.New("journalctl failed")) return m }, expectError: true, @@ -263,14 +265,16 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() { }, setupMock: func() log.Provider { m := logMocks.NewMockProvider(s.mockCtrl) - m.EXPECT().QueryUnit(gomock.Any(), "nginx.service", log.QueryOpts{}).Return([]log.Entry{ - { - Timestamp: "2026-01-01T00:00:00Z", - Unit: "nginx.service", - Priority: "info", - Message: "nginx started", - }, - }, nil) + m.EXPECT(). + QueryUnit(gomock.Any(), "nginx.service", log.QueryOpts{}). + Return([]log.Entry{ + { + Timestamp: "2026-01-01T00:00:00Z", + Unit: "nginx.service", + Priority: "info", + Message: "nginx started", + }, + }, nil) return m }, validate: func(result json.RawMessage) { @@ -306,7 +310,9 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() { }, setupMock: func() log.Provider { m := logMocks.NewMockProvider(s.mockCtrl) - m.EXPECT().QueryUnit(gomock.Any(), "nginx.service", gomock.Any()).Return(nil, errors.New("unit not found")) + m.EXPECT(). + QueryUnit(gomock.Any(), "nginx.service", gomock.Any()). + Return(nil, errors.New("unit not found")) return m }, expectError: true, diff --git a/internal/controller/api/node/log/log_unit_get_public_test.go b/internal/controller/api/node/log/log_unit_get_public_test.go index 76d416006..6e823e814 100644 --- a/internal/controller/api/node/log/log_unit_get_public_test.go +++ b/internal/controller/api/node/log/log_unit_get_public_test.go @@ -222,7 +222,9 @@ func (s *LogUnitPublicTestSuite) TestGetNodeLogUnit() { "server1": { JobID: "550e8400-e29b-41d4-a716-446655440000", Hostname: "server1", - Data: json.RawMessage(`[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"nginx started"}]`), + Data: json.RawMessage( + `[{"timestamp":"2026-01-01T00:00:00Z","priority":"info","message":"nginx started"}]`, + ), }, "server2": { JobID: "550e8400-e29b-41d4-a716-446655440000", diff --git a/internal/provider/node/log/darwin_public_test.go b/internal/provider/node/log/darwin_public_test.go index 6d05c4462..c0d6026f0 100644 --- a/internal/provider/node/log/darwin_public_test.go +++ b/internal/provider/node/log/darwin_public_test.go @@ -70,7 +70,11 @@ func (suite *DarwinPublicTestSuite) TestQueryUnit() { for _, tc := range tests { suite.Run(tc.name, func() { - got, err := suite.provider.QueryUnit(context.Background(), "nginx.service", oslog.QueryOpts{}) + got, err := suite.provider.QueryUnit( + context.Background(), + "nginx.service", + oslog.QueryOpts{}, + ) suite.Nil(got) suite.ErrorIs(err, provider.ErrUnsupported) diff --git a/internal/provider/node/log/linux_public_test.go b/internal/provider/node/log/linux_public_test.go index be3a3a096..a6a5d5bf5 100644 --- a/internal/provider/node/log/linux_public_test.go +++ b/internal/provider/node/log/linux_public_test.go @@ -70,7 +70,11 @@ func (suite *LinuxPublicTestSuite) TestQueryUnit() { for _, tc := range tests { suite.Run(tc.name, func() { - got, err := suite.provider.QueryUnit(context.Background(), "nginx.service", oslog.QueryOpts{}) + got, err := suite.provider.QueryUnit( + context.Background(), + "nginx.service", + oslog.QueryOpts{}, + ) suite.Nil(got) suite.ErrorIs(err, provider.ErrUnsupported) diff --git a/pkg/sdk/client/log_public_test.go b/pkg/sdk/client/log_public_test.go index 72e32e1fa..a36e0f83e 100644 --- a/pkg/sdk/client/log_public_test.go +++ b/pkg/sdk/client/log_public_test.go @@ -228,7 +228,7 @@ func (suite *LogPublicTestSuite) TestQueryWithOpts() { }{ { name: "when all options are set returns result", - handler: func(w http.ResponseWriter, r *http.Request) { + handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write( @@ -415,7 +415,12 @@ func (suite *LogPublicTestSuite) TestQueryUnit() { client.WithLogger(slog.Default()), ) - resp, err := sut.Log.QueryUnit(suite.ctx, "_any", "nginx.service", client.LogQueryOpts{}) + resp, err := sut.Log.QueryUnit( + suite.ctx, + "_any", + "nginx.service", + client.LogQueryOpts{}, + ) tc.validateFunc(resp, err) }) } From 7686fbd99049edbc8c2fcb50b5c756b0544a73cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 20:09:07 -0700 Subject: [PATCH 13/19] chore: fix docs formatting Co-Authored-By: Claude --- docs/docs/sidebar/features/authentication.md | 6 +- docs/docs/sidebar/features/log-management.md | 55 ++--- .../docs/sidebar/sdk/client/operations/log.md | 59 +++-- .../usage/cli/client/node/log/query.md | 18 +- .../sidebar/usage/cli/client/node/log/unit.md | 20 +- docs/docs/sidebar/usage/configuration.md | 6 +- ...26-03-31-log-management-provider-design.md | 87 ++++--- .../2026-03-31-log-management-provider.md | 222 ++++++++++++------ 8 files changed, 273 insertions(+), 200 deletions(-) diff --git a/docs/docs/sidebar/features/authentication.md b/docs/docs/sidebar/features/authentication.md index 4443513fe..0e430ac9e 100644 --- a/docs/docs/sidebar/features/authentication.md +++ b/docs/docs/sidebar/features/authentication.md @@ -60,11 +60,11 @@ flowchart TD Built-in roles expand to these default permissions: -| Role | Permissions | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Role | Permissions | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | | `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | -| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | +| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | ### Custom Roles diff --git a/docs/docs/sidebar/features/log-management.md b/docs/docs/sidebar/features/log-management.md index dc83becd6..1c8445245 100644 --- a/docs/docs/sidebar/features/log-management.md +++ b/docs/docs/sidebar/features/log-management.md @@ -4,27 +4,27 @@ sidebar_position: 23 # Log Management -OSAPI provides read-only access to the systemd journal on managed hosts. -Log entries are retrieved via `journalctl` and returned as structured -JSON with timestamp, unit, priority, message, PID, and hostname fields. +OSAPI provides read-only access to the systemd journal on managed hosts. Log +entries are retrieved via `journalctl` and returned as structured JSON with +timestamp, unit, priority, message, PID, and hostname fields. ## How It Works -The log provider queries the systemd journal on the agent host at -request time. Each request returns a live snapshot — up to the -requested number of entries matching the given filters. +The log provider queries the systemd journal on the agent host at request time. +Each request returns a live snapshot — up to the requested number of entries +matching the given filters. ### Query Returns journal log entries for the target host. The agent runs -`journalctl --output=json` with optional `--lines`, `--since`, and -`--priority` flags and returns the parsed entries. +`journalctl --output=json` with optional `--lines`, `--since`, and `--priority` +flags and returns the parsed entries. ### QueryUnit -Returns journal log entries scoped to a specific systemd unit. The -agent adds the `-u ` flag to the journalctl invocation alongside -the same optional filters. +Returns journal log entries scoped to a specific systemd unit. The agent adds +the `-u ` flag to the journalctl invocation alongside the same optional +filters. ## Operations @@ -54,8 +54,8 @@ All commands support `--json` for raw JSON output. ## Broadcast Support -All log operations support broadcast targeting. Use `--target _all` to -query logs on every registered agent, or use a label selector like +All log operations support broadcast targeting. Use `--target _all` to query +logs on every registered agent, or use a label selector like `--target group:web` to target a subset. Responses always include per-host results: @@ -79,31 +79,28 @@ Skipped and failed hosts appear with their error in the output. | Darwin | Skipped | | Linux | Skipped | -On unsupported platforms, log operations return `status: skipped` -instead of failing. See -[Platform Detection](../sdk/platform/detection.md) for details on OS -family detection. +On unsupported platforms, log operations return `status: skipped` instead of +failing. See [Platform Detection](../sdk/platform/detection.md) for details on +OS family detection. ## Container Behavior -Log operations return `status: skipped` inside containers. `journalctl` -requires a running systemd instance which is not available in -standard container environments. +Log operations return `status: skipped` inside containers. `journalctl` requires +a running systemd instance which is not available in standard container +environments. ## Permissions -| Operation | Permission | -| ------------------ | ---------- | -| Query, QueryUnit | `log:read` | +| Operation | Permission | +| ---------------- | ---------- | +| Query, QueryUnit | `log:read` | -Log querying requires `log:read`, included in all built-in roles -(`admin`, `write`, `read`). +Log querying requires `log:read`, included in all built-in roles (`admin`, +`write`, `read`). ## Related - [CLI Reference](../usage/cli/client/node/log/log.md) -- log commands - [SDK Reference](../sdk/client/operations/log.md) -- Log service -- [Platform Detection](../sdk/platform/detection.md) -- OS family - detection -- [Configuration](../usage/configuration.md) -- full configuration - reference +- [Platform Detection](../sdk/platform/detection.md) -- OS family detection +- [Configuration](../usage/configuration.md) -- full configuration reference diff --git a/docs/docs/sidebar/sdk/client/operations/log.md b/docs/docs/sidebar/sdk/client/operations/log.md index 39480fec5..9d203e221 100644 --- a/docs/docs/sidebar/sdk/client/operations/log.md +++ b/docs/docs/sidebar/sdk/client/operations/log.md @@ -4,22 +4,21 @@ sidebar_position: 4 # Log -The `Log` service provides methods for querying the systemd journal on -target hosts. Access via `client.Log.Query()` and -`client.Log.QueryUnit()`. +The `Log` service provides methods for querying the systemd journal on target +hosts. Access via `client.Log.Query()` and `client.Log.QueryUnit()`. ## Methods -| Method | Description | -| --------------------------------------- | ---------------------------------------------- | -| `Query(ctx, hostname, opts)` | Query journal entries for the host | -| `QueryUnit(ctx, hostname, unit, opts)` | Query journal entries for a specific unit | +| Method | Description | +| -------------------------------------- | ----------------------------------------- | +| `Query(ctx, hostname, opts)` | Query journal entries for the host | +| `QueryUnit(ctx, hostname, unit, opts)` | Query journal entries for a specific unit | ## Request Types -| Type | Fields | -| -------------- | -------------------------------------------------------------------- | -| `LogQueryOpts` | `Lines` (`*int`), `Since` (`*string`), `Priority` (`*string`) | +| Type | Fields | +| -------------- | ------------------------------------------------------------- | +| `LogQueryOpts` | `Lines` (`*int`), `Since` (`*string`), `Priority` (`*string`) | ## Usage @@ -66,23 +65,23 @@ resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{}) `LogEntryResult` is returned per host in the `Collection.Results` slice: -| Field | Type | Description | -| ---------- | ----------- | ---------------------------------- | -| `Hostname` | `string` | Target host | -| `Status` | `string` | `ok`, `skipped`, or `failed` | -| `Entries` | `[]LogEntry`| Journal entries (nil if none) | -| `Error` | `string` | Error message if the call failed | +| Field | Type | Description | +| ---------- | ------------ | -------------------------------- | +| `Hostname` | `string` | Target host | +| `Status` | `string` | `ok`, `skipped`, or `failed` | +| `Entries` | `[]LogEntry` | Journal entries (nil if none) | +| `Error` | `string` | Error message if the call failed | `LogEntry` fields: -| Field | Type | Description | -| ----------- | -------- | ------------------------------------- | -| `Timestamp` | `string` | ISO 8601 timestamp | -| `Unit` | `string` | Systemd unit name | -| `Priority` | `string` | Log priority (e.g., `info`, `err`) | -| `Message` | `string` | Log message text | -| `PID` | `int` | Process ID that generated the entry | -| `Hostname` | `string` | Hostname from the journal entry | +| Field | Type | Description | +| ----------- | -------- | ----------------------------------- | +| `Timestamp` | `string` | ISO 8601 timestamp | +| `Unit` | `string` | Systemd unit name | +| `Priority` | `string` | Log priority (e.g., `info`, `err`) | +| `Message` | `string` | Log message text | +| `PID` | `int` | Process ID that generated the entry | +| `Hostname` | `string` | Hostname from the journal entry | ## Example @@ -90,10 +89,10 @@ resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{}) ## Permissions -| Operation | Permission | -| ------------------ | ---------- | -| Query, QueryUnit | `log:read` | +| Operation | Permission | +| ---------------- | ---------- | +| Query, QueryUnit | `log:read` | -Log management is supported on the Debian OS family (Ubuntu, Debian, -Raspbian). On unsupported platforms (Darwin, generic Linux) and inside -containers, operations return `status: skipped`. +Log management is supported on the Debian OS family (Ubuntu, Debian, Raspbian). +On unsupported platforms (Darwin, generic Linux) and inside containers, +operations return `status: skipped`. diff --git a/docs/docs/sidebar/usage/cli/client/node/log/query.md b/docs/docs/sidebar/usage/cli/client/node/log/query.md index a9eb29720..98da83441 100644 --- a/docs/docs/sidebar/usage/cli/client/node/log/query.md +++ b/docs/docs/sidebar/usage/cli/client/node/log/query.md @@ -55,12 +55,12 @@ $ osapi client node log query --target web-01 --lines 1 --json ## Flags -| Flag | Description | Default | -| ----------------- | -------------------------------------------------------- | ------- | -| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | -| `--lines` | Maximum number of log lines to return | `100` | -| `--since` | Return entries since this time (e.g., `1h`, | | -| | `2026-01-01 00:00:00`) | | -| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | -| | `info`) | | -| `-j, --json` | Output raw JSON response | | +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | +| `--lines` | Maximum number of log lines to return | `100` | +| `--since` | Return entries since this time (e.g., `1h`, | | +| | `2026-01-01 00:00:00`) | | +| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | +| | `info`) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/cli/client/node/log/unit.md b/docs/docs/sidebar/usage/cli/client/node/log/unit.md index 3c567d791..ff4161e72 100644 --- a/docs/docs/sidebar/usage/cli/client/node/log/unit.md +++ b/docs/docs/sidebar/usage/cli/client/node/log/unit.md @@ -56,13 +56,13 @@ $ osapi client node log unit --target web-01 --name sshd.service \ ## Flags -| Flag | Description | Default | -| ----------------- | -------------------------------------------------------- | ------- | -| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | -| `--name` | Systemd unit name (required, e.g., `sshd.service`) | | -| `--lines` | Maximum number of log lines to return | `100` | -| `--since` | Return entries since this time (e.g., `1h`, | | -| | `2026-01-01 00:00:00`) | | -| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | -| | `info`) | | -| `-j, --json` | Output raw JSON response | | +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | +| `--name` | Systemd unit name (required, e.g., `sshd.service`) | | +| `--lines` | Maximum number of log lines to return | `100` | +| `--since` | Return entries since this time (e.g., `1h`, | | +| | `2026-01-01 00:00:00`) | | +| `--priority` | Filter by priority level (e.g., `err`, `warning`, | | +| | `info`) | | +| `-j, --json` | Output raw JSON response | | diff --git a/docs/docs/sidebar/usage/configuration.md b/docs/docs/sidebar/usage/configuration.md index c58a3fcb6..7292abfbf 100644 --- a/docs/docs/sidebar/usage/configuration.md +++ b/docs/docs/sidebar/usage/configuration.md @@ -154,11 +154,11 @@ OSAPI uses fine-grained `resource:verb` permissions for access control. Each API endpoint requires a specific permission. Built-in roles expand to a default set of permissions: -| Role | Permissions | -| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Role | Permissions | +| ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `admin` | `agent:read`, `agent:write`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `audit:read`, `command:execute`, `file:read`, `file:write`, `docker:read`, `docker:write`, `docker:execute`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `power:execute`, `process:read`, `process:execute`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | | `write` | `agent:read`, `node:read`, `node:write`, `network:read`, `network:write`, `job:read`, `job:write`, `health:read`, `file:read`, `file:write`, `docker:read`, `docker:write`, `cron:read`, `cron:write`, `sysctl:read`, `sysctl:write`, `ntp:read`, `ntp:write`, `timezone:read`, `timezone:write`, `process:read`, `user:read`, `user:write`, `package:read`, `package:write`, `log:read` | -| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | +| `read` | `agent:read`, `node:read`, `network:read`, `job:read`, `health:read`, `file:read`, `docker:read`, `cron:read`, `sysctl:read`, `ntp:read`, `timezone:read`, `process:read`, `user:read`, `package:read`, `log:read` | ### Custom Roles diff --git a/docs/plans/2026-03-31-log-management-provider-design.md b/docs/plans/2026-03-31-log-management-provider-design.md index 9bca96444..7d27bac1e 100644 --- a/docs/plans/2026-03-31-log-management-provider-design.md +++ b/docs/plans/2026-03-31-log-management-provider-design.md @@ -2,10 +2,9 @@ ## Overview -Add log viewing to OSAPI. Query systemd journal entries with -optional filtering by lines, time range, and priority. Read-only -— no write operations. Uses `journalctl --output=json` for -structured parsing. +Add log viewing to OSAPI. Query systemd journal entries with optional filtering +by lines, time range, and priority. Read-only — no write operations. Uses +`journalctl --output=json` for structured parsing. ## Architecture @@ -46,66 +45,67 @@ type Entry struct { ## Debian Implementation -- **Query**: run `journalctl --output=json -n ` with - optional `--since=` and `--priority=`. Parse - JSON lines output — each line is a JSON object with fields - `__REALTIME_TIMESTAMP`, `SYSLOG_IDENTIFIER`, `PRIORITY`, - `MESSAGE`, `_PID`, `_HOSTNAME`. +- **Query**: run `journalctl --output=json -n ` with optional + `--since=` and `--priority=`. Parse JSON lines output — each + line is a JSON object with fields `__REALTIME_TIMESTAMP`, `SYSLOG_IDENTIFIER`, + `PRIORITY`, `MESSAGE`, `_PID`, `_HOSTNAME`. - **QueryUnit**: same but with `-u ` flag. -Default `lines` is 100 if not specified. `since` uses journalctl -format (e.g., `"1 hour ago"`, `"2026-03-31"`). `priority` uses -journalctl levels (0-7 or names like `err`, `warning`). +Default `lines` is 100 if not specified. `since` uses journalctl format (e.g., +`"1 hour ago"`, `"2026-03-31"`). `priority` uses journalctl levels (0-7 or names +like `err`, `warning`). ## Platform Implementations -| Platform | Implementation | -| -------- | -------------------------- | -| Debian | journalctl --output=json | -| Darwin | ErrUnsupported | -| Linux | ErrUnsupported | +| Platform | Implementation | +| -------- | ------------------------ | +| Debian | journalctl --output=json | +| Darwin | ErrUnsupported | +| Linux | ErrUnsupported | ## Container Behavior -Return `ErrUnsupported` in containers — `journalctl` requires -systemd which isn't available in containers. +Return `ErrUnsupported` in containers — `journalctl` requires systemd which +isn't available in containers. ## API Endpoints -| Method | Path | Permission | Description | -| ------ | --------------------------------- | ---------- | --------------------------- | -| `GET` | `/node/{hostname}/log` | `log:read` | Query journal entries | -| `GET` | `/node/{hostname}/log/unit/{name}`| `log:read` | Query entries for a unit | +| Method | Path | Permission | Description | +| ------ | ---------------------------------- | ---------- | ------------------------ | +| `GET` | `/node/{hostname}/log` | `log:read` | Query journal entries | +| `GET` | `/node/{hostname}/log/unit/{name}` | `log:read` | Query entries for a unit | All endpoints support broadcast targeting. ### Query Parameters -| Param | Type | Default | Description | -| ---------- | ------- | ------- | ------------------------------------------ | -| `lines` | integer | 100 | Number of entries to return | -| `since` | string | | Time filter (e.g., "1 hour ago") | -| `priority` | string | | Minimum priority (emerg..debug or 0-7) | +| Param | Type | Default | Description | +| ---------- | ------- | ------- | -------------------------------------- | +| `lines` | integer | 100 | Number of entries to return | +| `since` | string | | Time filter (e.g., "1 hour ago") | +| `priority` | string | | Minimum priority (emerg..debug or 0-7) | ### Response Shape ```json { "job_id": "...", - "results": [{ - "hostname": "web-01", - "status": "ok", - "entries": [ - { - "timestamp": "2026-03-31T22:30:45.123Z", - "unit": "nginx.service", - "priority": "info", - "message": "Started nginx", - "pid": 1234, - "hostname": "web-01" - } - ] - }] + "results": [ + { + "hostname": "web-01", + "status": "ok", + "entries": [ + { + "timestamp": "2026-03-31T22:30:45.123Z", + "unit": "nginx.service", + "priority": "info", + "message": "Started nginx", + "pid": 1234, + "hostname": "web-01" + } + ] + } + ] } ``` @@ -120,5 +120,4 @@ client.Log.QueryUnit(ctx, host, unit, opts) ## Permissions -- `log:read` — query journal entries. Added to admin, write, and - read roles. +- `log:read` — query journal entries. Added to admin, write, and read roles. diff --git a/docs/plans/2026-03-31-log-management-provider.md b/docs/plans/2026-03-31-log-management-provider.md index 097415990..77a26a3aa 100644 --- a/docs/plans/2026-03-31-log-management-provider.md +++ b/docs/plans/2026-03-31-log-management-provider.md @@ -1,19 +1,29 @@ # Log Management Provider Implementation Plan -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps use +> checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add read-only log viewing to OSAPI via `journalctl --output=json`, with optional filtering by lines, time range, and priority. +**Goal:** Add read-only log viewing to OSAPI via `journalctl --output=json`, +with optional filtering by lines, time range, and priority. -**Architecture:** Direct provider at `internal/provider/node/log/` using `exec.Manager` to run `journalctl`. Two API endpoints: query all journal entries and query by unit name. Read-only with `log:read` permission added to all built-in roles. +**Architecture:** Direct provider at `internal/provider/node/log/` using +`exec.Manager` to run `journalctl`. Two API endpoints: query all journal entries +and query by unit name. Read-only with `log:read` permission added to all +built-in roles. -**Tech Stack:** Go, exec.Manager, journalctl JSON output, oapi-codegen strict-server +**Tech Stack:** Go, exec.Manager, journalctl JSON output, oapi-codegen +strict-server --- ## File Structure ### Provider Layer -- Create: `internal/provider/node/log/types.go` — Provider interface + domain types + +- Create: `internal/provider/node/log/types.go` — Provider interface + domain + types - Create: `internal/provider/node/log/debian.go` — journalctl implementation - Create: `internal/provider/node/log/debian_query.go` — shared query logic - Create: `internal/provider/node/log/darwin.go` — macOS stub @@ -24,12 +34,14 @@ - Test: `internal/provider/node/log/linux_public_test.go` ### Agent Layer + - Create: `internal/agent/processor_log.go` — log operation dispatcher - Modify: `internal/agent/processor.go` — add `log` case + logProvider param - Modify: `cmd/agent_setup.go` — create log provider factory, wire into registry - Test: `internal/agent/processor_log_public_test.go` ### API Layer + - Create: `internal/controller/api/node/log/gen/api.yaml` — OpenAPI spec - Create: `internal/controller/api/node/log/gen/cfg.yaml` — oapi-codegen config - Create: `internal/controller/api/node/log/gen/generate.go` — go:generate @@ -37,7 +49,8 @@ - Create: `internal/controller/api/node/log/log.go` — New(), compile-time check - Create: `internal/controller/api/node/log/validate.go` — validateHostname - Create: `internal/controller/api/node/log/log_query_get.go` — query handler -- Create: `internal/controller/api/node/log/log_unit_get.go` — query unit handler +- Create: `internal/controller/api/node/log/log_unit_get.go` — query unit + handler - Create: `internal/controller/api/node/log/handler.go` — Handler() registration - Modify: `cmd/controller_setup.go` — register log handler - Test: `internal/controller/api/node/log/log_query_get_public_test.go` @@ -45,12 +58,14 @@ - Test: `internal/controller/api/node/log/handler_public_test.go` ### Operations & Permissions + - Modify: `pkg/sdk/client/operations.go` — add log operation constants - Modify: `internal/job/types.go` — add log operation aliases - Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead` - Modify: `internal/authtoken/permissions.go` — add `PermLogRead` to all roles ### SDK Layer + - Create: `pkg/sdk/client/log.go` — LogService methods - Create: `pkg/sdk/client/log_types.go` — SDK result types + conversions - Modify: `pkg/sdk/client/osapi.go` — add Log field @@ -58,11 +73,13 @@ - Test: `pkg/sdk/client/log_types_public_test.go` ### CLI Layer + - Create: `cmd/client_node_log.go` — parent command - Create: `cmd/client_node_log_query.go` — query subcommand - Create: `cmd/client_node_log_unit.go` — query-unit subcommand ### Documentation + - Create: `docs/docs/sidebar/features/log-management.md` — feature page - Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md` — CLI landing - Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md` — query CLI doc @@ -71,12 +88,15 @@ - Create: `examples/sdk/client/log.go` — SDK example - Modify: `docs/docs/sidebar/features/features.md` — add log to table - Modify: `docs/docs/sidebar/features/authentication.md` — add log:read to roles -- Modify: `docs/docs/sidebar/usage/configuration.md` — add log:read to permissions -- Modify: `docs/docs/sidebar/architecture/architecture.md` — add log feature link +- Modify: `docs/docs/sidebar/usage/configuration.md` — add log:read to + permissions +- Modify: `docs/docs/sidebar/architecture/architecture.md` — add log feature + link - Modify: `docs/docs/sidebar/architecture/api-guidelines.md` — add log endpoints - Modify: `docs/docusaurus.config.ts` — add SDK dropdown + features dropdown ### Integration Test + - Create: `test/integration/log_test.go` — smoke test --- @@ -84,6 +104,7 @@ ### Task 1: Provider Interface and Types **Files:** + - Create: `internal/provider/node/log/types.go` - [ ] **Step 1: Create provider interface and types** @@ -144,8 +165,7 @@ type Entry struct { - [ ] **Step 2: Verify it compiles** -Run: `go build ./internal/provider/node/log/...` -Expected: PASS +Run: `go build ./internal/provider/node/log/...` Expected: PASS - [ ] **Step 3: Commit** @@ -159,6 +179,7 @@ git commit -m "feat(log): add provider interface and types" ### Task 2: Platform Stubs (Darwin + Linux) **Files:** + - Create: `internal/provider/node/log/darwin.go` - Create: `internal/provider/node/log/linux.go` - Test: `internal/provider/node/log/darwin_public_test.go` @@ -292,8 +313,8 @@ func TestLinuxPublicTestSuite(t *testing.T) { - [ ] **Step 3: Run tests to verify they fail** -Run: `go test -v ./internal/provider/node/log/...` -Expected: FAIL — Darwin and Linux types don't exist yet +Run: `go test -v ./internal/provider/node/log/...` Expected: FAIL — Darwin and +Linux types don't exist yet - [ ] **Step 4: Implement darwin stub** @@ -415,8 +436,8 @@ func (l *Linux) QueryUnit( - [ ] **Step 6: Run tests to verify they pass** -Run: `go test -v ./internal/provider/node/log/...` -Expected: PASS — all 4 tests pass +Run: `go test -v ./internal/provider/node/log/...` Expected: PASS — all 4 tests +pass - [ ] **Step 7: Commit** @@ -431,6 +452,7 @@ git commit -m "feat(log): add darwin and linux provider stubs" ### Task 3: Debian Provider Implementation **Files:** + - Create: `internal/provider/node/log/debian.go` - Create: `internal/provider/node/log/debian_query.go` - Create: `internal/provider/node/log/mocks/generate.go` @@ -467,8 +489,8 @@ package mocks - [ ] **Step 2: Generate mocks** -Run: `go generate ./internal/provider/node/log/mocks/...` -Expected: generates `provider.gen.go` +Run: `go generate ./internal/provider/node/log/mocks/...` Expected: generates +`provider.gen.go` - [ ] **Step 3: Write debian provider tests** @@ -738,8 +760,8 @@ func TestDebianPublicTestSuite(t *testing.T) { - [ ] **Step 4: Run tests to verify they fail** -Run: `go test -v ./internal/provider/node/log/...` -Expected: FAIL — Debian type doesn't exist yet +Run: `go test -v ./internal/provider/node/log/...` Expected: FAIL — Debian type +doesn't exist yet - [ ] **Step 5: Implement debian.go** @@ -1011,8 +1033,8 @@ func parseTimestamp( - [ ] **Step 7: Run tests to verify they pass** -Run: `go test -v ./internal/provider/node/log/...` -Expected: PASS — all tests pass +Run: `go test -v ./internal/provider/node/log/...` Expected: PASS — all tests +pass - [ ] **Step 8: Commit** @@ -1028,6 +1050,7 @@ git commit -m "feat(log): add debian provider with journalctl parsing" ### Task 4: Operations, Permissions, and Agent Wiring **Files:** + - Modify: `pkg/sdk/client/operations.go` — add log operations - Modify: `internal/job/types.go` — add log operation aliases - Modify: `pkg/sdk/client/permissions.go` — add `PermLogRead` @@ -1074,33 +1097,42 @@ Add to `pkg/sdk/client/permissions.go` after the Package permissions: Add to `internal/authtoken/permissions.go`: 1. Add constant after PackageWrite: + ```go PermLogRead = client.PermLogRead ``` 2. Add to `AllPermissions` slice: + ```go PermLogRead, ``` 3. Add to admin role after `PermPackageWrite`: + ```go PermLogRead, ``` 4. Add to write role after `PermPackageWrite`: + ```go PermLogRead, ``` 5. Add to read role after `PermPackageRead`: + ```go PermLogRead, ``` - [ ] **Step 5: Write agent processor tests** -Create `internal/agent/processor_log_public_test.go`. The tests exercise `processLogOperation` via the node processor's `log` case. Follow the same pattern as `processor_process_public_test.go` — table-driven tests with gomock for the log provider mock. Test cases: +Create `internal/agent/processor_log_public_test.go`. The tests exercise +`processLogOperation` via the node processor's `log` case. Follow the same +pattern as `processor_process_public_test.go` — table-driven tests with gomock +for the log provider mock. Test cases: + - `log.query` with default opts (empty data) - `log.query` with all options (lines, since, priority) - `log.queryUnit` with unit name @@ -1108,7 +1140,12 @@ Create `internal/agent/processor_log_public_test.go`. The tests exercise `proces - invalid operation format (`log` with no sub-op) - nil log provider -The tests construct a `job.Request` with `Operation: "log.query"` (note: the node processor strips the base operation from the dotted format `"hostname.get"` → `"hostname"`, but for log the operation string passed to `processLogOperation` is already `"log.query"`, `"log.queryUnit"` etc. The node processor matches on `baseOperation` which is `"log"`, then delegates to `processLogOperation` which splits on `.` to get the sub-op). +The tests construct a `job.Request` with `Operation: "log.query"` (note: the +node processor strips the base operation from the dotted format `"hostname.get"` +→ `"hostname"`, but for log the operation string passed to `processLogOperation` +is already `"log.query"`, `"log.queryUnit"` etc. The node processor matches on +`baseOperation` which is `"log"`, then delegates to `processLogOperation` which +splits on `.` to get the sub-op). - [ ] **Step 6: Run tests to verify they fail** @@ -1231,7 +1268,8 @@ func processLogQueryUnit( - [ ] **Step 8: Wire log into NewNodeProcessor** -Add `logProvider logProv.Provider` parameter to `NewNodeProcessor` in `internal/agent/processor.go`. Add the import: +Add `logProvider logProv.Provider` parameter to `NewNodeProcessor` in +`internal/agent/processor.go`. Add the import: ```go logProv "github.com/retr0h/osapi/internal/provider/node/log" @@ -1247,11 +1285,13 @@ Add the case in the switch: - [ ] **Step 9: Create log provider factory in agent_setup.go** Add import: + ```go logProv "github.com/retr0h/osapi/internal/provider/node/log" ``` Add factory function: + ```go // createLogProvider creates a platform-specific log provider. On Debian, the // log provider reads journal entries via journalctl. In containers, journalctl @@ -1279,17 +1319,18 @@ func createLogProvider( ``` Add to `setupAgent` after `packageProvider`: + ```go // --- Log provider --- logProvider := createLogProvider(log, execManager) ``` -Add `logProvider` to the `NewNodeProcessor` call and the providers list in `registry.Register("node", ...)`. +Add `logProvider` to the `NewNodeProcessor` call and the providers list in +`registry.Register("node", ...)`. - [ ] **Step 10: Run tests** -Run: `go test -v ./internal/agent/... && go build ./...` -Expected: PASS +Run: `go test -v ./internal/agent/... && go build ./...` Expected: PASS - [ ] **Step 11: Commit** @@ -1306,6 +1347,7 @@ git commit -m "feat(log): add operations, permissions, and agent wiring" ### Task 5: OpenAPI Spec and Code Generation **Files:** + - Create: `internal/controller/api/node/log/gen/api.yaml` - Create: `internal/controller/api/node/log/gen/cfg.yaml` - Create: `internal/controller/api/node/log/gen/generate.go` @@ -1350,8 +1392,8 @@ paths: get: summary: Query journal entries description: > - Query systemd journal entries on the target node with optional - filtering by lines, time range, and priority. + Query systemd journal entries on the target node with optional filtering + by lines, time range, and priority. tags: - log_operations operationId: GetNodeLog @@ -1375,16 +1417,15 @@ paths: in: query required: false description: > - Time filter in journalctl format (e.g., "1 hour ago", - "2026-03-31"). + Time filter in journalctl format (e.g., "1 hour ago", "2026-03-31"). schema: type: string - name: priority in: query required: false description: > - Minimum priority level (0-7 or name: emerg, alert, crit, - err, warning, notice, info, debug). + Minimum priority level (0-7 or name: emerg, alert, crit, err, + warning, notice, info, debug). schema: type: string responses: @@ -1417,8 +1458,8 @@ paths: get: summary: Query journal entries for a unit description: > - Query systemd journal entries for a specific unit on the target - node with optional filtering. + Query systemd journal entries for a specific unit on the target node + with optional filtering. tags: - log_operations operationId: GetNodeLogUnit @@ -1443,16 +1484,15 @@ paths: in: query required: false description: > - Time filter in journalctl format (e.g., "1 hour ago", - "2026-03-31"). + Time filter in journalctl format (e.g., "1 hour ago", "2026-03-31"). schema: type: string - name: priority in: query required: false description: > - Minimum priority level (0-7 or name: emerg, alert, crit, - err, warning, notice, info, debug). + Minimum priority level (0-7 or name: emerg, alert, crit, err, + warning, notice, info, debug). schema: type: string responses: @@ -1490,8 +1530,8 @@ components: in: path required: true description: > - Target agent hostname, reserved routing value (_any, _all), - or label selector (key:value). + Target agent hostname, reserved routing value (_any, _all), or label + selector (key:value). # NOTE: x-oapi-codegen-extra-tags on path params do not generate # validate tags in strict-server mode. Validation is handled # manually in handlers via validateHostname(). @@ -1535,19 +1575,19 @@ components: timestamp: type: string description: Entry timestamp in RFC3339 format. - example: "2026-03-31T22:30:45.123456Z" + example: '2026-03-31T22:30:45.123456Z' unit: type: string description: Systemd unit or syslog identifier. - example: "nginx.service" + example: 'nginx.service' priority: type: string description: Priority level name. - example: "info" + example: 'info' message: type: string description: Log message. - example: "Started nginx" + example: 'Started nginx' pid: type: integer description: Process ID that generated the entry. @@ -1555,7 +1595,7 @@ components: hostname: type: string description: Hostname where the entry originated. - example: "web-01" + example: 'web-01' LogResultEntry: type: object @@ -1589,7 +1629,7 @@ components: type: string format: uuid description: The job ID used to process this request. - example: "550e8400-e29b-41d4-a716-446655440000" + example: '550e8400-e29b-41d4-a716-446655440000' results: type: array items: @@ -1670,13 +1710,12 @@ package gen - [ ] **Step 4: Generate code** -Run: `go generate ./internal/controller/api/node/log/gen/...` -Expected: generates `log.gen.go` +Run: `go generate ./internal/controller/api/node/log/gen/...` Expected: +generates `log.gen.go` - [ ] **Step 5: Regenerate combined spec** -Run: `just generate` -Expected: combined spec updated, all code regenerates +Run: `just generate` Expected: combined spec updated, all code regenerates - [ ] **Step 6: Commit** @@ -1690,6 +1729,7 @@ git commit -m "feat(log): add OpenAPI spec and generated code" ### Task 6: API Handler Implementation **Files:** + - Create: `internal/controller/api/node/log/types.go` - Create: `internal/controller/api/node/log/log.go` - Create: `internal/controller/api/node/log/validate.go` @@ -1704,6 +1744,7 @@ git commit -m "feat(log): add OpenAPI spec and generated code" - [ ] **Step 1: Create handler types, factory, and validate** Create `internal/controller/api/node/log/types.go`: + ```go package log @@ -1722,6 +1763,7 @@ type Log struct { ``` Create `internal/controller/api/node/log/log.go`: + ```go package log @@ -1748,6 +1790,7 @@ func New( ``` Create `internal/controller/api/node/log/validate.go`: + ```go package log @@ -1769,7 +1812,9 @@ func validateHostname( - [ ] **Step 2: Write handler tests for GetNodeLog** -Create `internal/controller/api/node/log/log_query_get_public_test.go` — follow the same pattern as `process_list_get_public_test.go`. Test cases: +Create `internal/controller/api/node/log/log_query_get_public_test.go` — follow +the same pattern as `process_list_get_public_test.go`. Test cases: + - success (single target) - skipped (single target) - broadcast success @@ -1779,11 +1824,15 @@ Create `internal/controller/api/node/log/log_query_get_public_test.go` — follo - TestGetNodeLogHTTP (raw HTTP through middleware) - TestGetNodeLogRBACHTTP (auth: 401, 403, 200) -The test should mock `s.mockJobClient.EXPECT().Query(...)` with category `"node"` and operation `job.OperationLogQuery`. The handler must pass query params (`lines`, `since`, `priority`) as JSON data. +The test should mock `s.mockJobClient.EXPECT().Query(...)` with category +`"node"` and operation `job.OperationLogQuery`. The handler must pass query +params (`lines`, `since`, `priority`) as JSON data. - [ ] **Step 3: Write handler tests for GetNodeLogUnit** -Create `internal/controller/api/node/log/log_unit_get_public_test.go` — same pattern. Test cases: +Create `internal/controller/api/node/log/log_unit_get_public_test.go` — same +pattern. Test cases: + - success (single target) - skipped (single target) - broadcast success @@ -1792,7 +1841,8 @@ Create `internal/controller/api/node/log/log_unit_get_public_test.go` — same p - TestGetNodeLogUnitHTTP - TestGetNodeLogUnitRBACHTTP -The handler must pass `unit` (from path param) and query params as JSON data with operation `job.OperationLogQueryUnit`. +The handler must pass `unit` (from path param) and query params as JSON data +with operation `job.OperationLogQueryUnit`. - [ ] **Step 4: Implement GetNodeLog handler** @@ -1988,15 +2038,21 @@ func (s *Log) getNodeLogBroadcast( } ``` -Note: The import for `validation` is `"github.com/retr0h/osapi/internal/validation"`. All files need full license headers. +Note: The import for `validation` is +`"github.com/retr0h/osapi/internal/validation"`. All files need full license +headers. - [ ] **Step 5: Implement GetNodeLogUnit handler** -Create `internal/controller/api/node/log/log_unit_get.go` — same pattern as `log_query_get.go` but adds unit from `request.Name` path param. Passes `{"unit":"...","lines":...,"since":"...","priority":"..."}` as job data. Uses `job.OperationLogQueryUnit`. +Create `internal/controller/api/node/log/log_unit_get.go` — same pattern as +`log_query_get.go` but adds unit from `request.Name` path param. Passes +`{"unit":"...","lines":...,"since":"...","priority":"..."}` as job data. Uses +`job.OperationLogQueryUnit`. - [ ] **Step 6: Implement handler.go** -Create `internal/controller/api/node/log/handler.go` — same pattern as `internal/controller/api/node/process/handler.go`: +Create `internal/controller/api/node/log/handler.go` — same pattern as +`internal/controller/api/node/process/handler.go`: ```go package log @@ -2050,18 +2106,21 @@ func Handler( - [ ] **Step 7: Register handler in controller_setup.go** Add import: + ```go logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" ``` Add after the `packageAPI.Handler(...)` line: + ```go handlers = append(handlers, logAPI.Handler(log, jc, signingKey, customRoles)...) ``` - [ ] **Step 8: Write handler_public_test.go** -Test route registration and middleware execution (same pattern as other handler tests). +Test route registration and middleware execution (same pattern as other handler +tests). - [ ] **Step 9: Run tests** @@ -2080,6 +2139,7 @@ git commit -m "feat(log): add API handlers with broadcast support" ### Task 7: SDK Service **Files:** + - Create: `pkg/sdk/client/log.go` - Create: `pkg/sdk/client/log_types.go` - Modify: `pkg/sdk/client/osapi.go` @@ -2088,7 +2148,9 @@ git commit -m "feat(log): add API handlers with broadcast support" - [ ] **Step 1: Write SDK service tests** -Create `pkg/sdk/client/log_public_test.go` — test with `httptest.Server`. Test cases for `Query` and `QueryUnit`: +Create `pkg/sdk/client/log_public_test.go` — test with `httptest.Server`. Test +cases for `Query` and `QueryUnit`: + - success (200) - auth error (401, 403) - server error (500) @@ -2098,6 +2160,7 @@ Create `pkg/sdk/client/log_public_test.go` — test with `httptest.Server`. Test - [ ] **Step 2: Write SDK types tests** Create `pkg/sdk/client/log_types_public_test.go` — test conversion functions: + - `logCollectionFromGen` with full data - `logCollectionFromGen` with error entries - `logEntryInfoFromGen` field mapping @@ -2283,25 +2346,26 @@ func (s *LogService) QueryUnit( - [ ] **Step 5: Wire LogService in osapi.go** Add field to Client struct: + ```go // Log provides log viewing operations (query journal entries). Log *LogService ``` Add initialization in `New()`: + ```go c.Log = &LogService{client: httpClient} ``` - [ ] **Step 6: Regenerate SDK client** -Run: `go generate ./pkg/sdk/client/gen/...` -Expected: SDK client picks up log endpoints +Run: `go generate ./pkg/sdk/client/gen/...` Expected: SDK client picks up log +endpoints - [ ] **Step 7: Run tests** -Run: `go test -v ./pkg/sdk/client/...` -Expected: PASS +Run: `go test -v ./pkg/sdk/client/...` Expected: PASS - [ ] **Step 8: Commit** @@ -2317,6 +2381,7 @@ git commit -m "feat(log): add SDK service with tests" ### Task 8: CLI Commands **Files:** + - Create: `cmd/client_node_log.go` - Create: `cmd/client_node_log_query.go` - Create: `cmd/client_node_log_unit.go` @@ -2568,8 +2633,8 @@ func init() { - [ ] **Step 4: Build and verify** -Run: `go build ./... && go run main.go client node log --help` -Expected: shows `query` and `unit` subcommands +Run: `go build ./... && go run main.go client node log --help` Expected: shows +`query` and `unit` subcommands - [ ] **Step 5: Commit** @@ -2583,6 +2648,7 @@ git commit -m "feat(log): add CLI commands for journal log viewing" ### Task 9: Documentation and SDK Example **Files:** + - Create: `docs/docs/sidebar/features/log-management.md` - Create: `docs/docs/sidebar/usage/cli/client/node/log/log.md` - Create: `docs/docs/sidebar/usage/cli/client/node/log/query.md` @@ -2598,7 +2664,9 @@ git commit -m "feat(log): add CLI commands for journal log viewing" - [ ] **Step 1: Create feature page** -Create `docs/docs/sidebar/features/log-management.md` following the process-management.md template. Include: +Create `docs/docs/sidebar/features/log-management.md` following the +process-management.md template. Include: + - How It Works (Query, QueryUnit) - Operations table - CLI Usage examples @@ -2611,6 +2679,7 @@ Create `docs/docs/sidebar/features/log-management.md` following the process-mana - [ ] **Step 2: Create CLI doc pages** Create landing page `docs/docs/sidebar/usage/cli/client/node/log/log.md`: + ```markdown --- sidebar_position: 1 @@ -2621,22 +2690,27 @@ sidebar_position: 1 ``` -Create `query.md` and `unit.md` pages with usage examples, flags, and output samples. +Create `query.md` and `unit.md` pages with usage examples, flags, and output +samples. - [ ] **Step 3: Create SDK doc page** -Create `docs/docs/sidebar/sdk/client/operations/log.md` following existing SDK doc patterns. Document `Query` and `QueryUnit` methods with code examples. +Create `docs/docs/sidebar/sdk/client/operations/log.md` following existing SDK +doc patterns. Document `Query` and `QueryUnit` methods with code examples. - [ ] **Step 4: Create SDK example** -Create `examples/sdk/client/log.go` — demonstrate `Query` and `QueryUnit` with error handling and result printing. Under ~100 lines. +Create `examples/sdk/client/log.go` — demonstrate `Query` and `QueryUnit` with +error handling and result printing. Under ~100 lines. - [ ] **Step 5: Update cross-references** Update these files to add log management: + - `features/features.md` — add row to features table - `features/authentication.md` — add `log:read` to all three role tables -- `usage/configuration.md` — add `log:read` to permissions comments and role tables +- `usage/configuration.md` — add `log:read` to permissions comments and role + tables - `architecture/architecture.md` — add log feature link - `architecture/api-guidelines.md` — add log endpoint rows to path pattern table - `docusaurus.config.ts` — add to Features dropdown and SDK dropdown @@ -2653,13 +2727,17 @@ git commit -m "docs: add log management feature docs, SDK example, and cross-ref ### Task 10: Integration Test **Files:** + - Create: `test/integration/log_test.go` - [ ] **Step 1: Write integration test** -Create `test/integration/log_test.go` with `//go:build integration` tag. Follow the pattern of existing integration tests. Test: +Create `test/integration/log_test.go` with `//go:build integration` tag. Follow +the pattern of existing integration tests. Test: + - `osapi client node log query --target _any --json` → verify JSON output -- `osapi client node log unit --target _any --name sshd.service --json` → verify JSON output or graceful error +- `osapi client node log unit --target _any --name sshd.service --json` → verify + JSON output or graceful error - [ ] **Step 2: Commit** From 045897d639ac2e775ddcbde39b40e2d74d33f312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 20:12:37 -0700 Subject: [PATCH 14/19] fix(log): pass opts struct directly to job client to avoid double-encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Query/QueryBroadcast methods accept `any` and call json.Marshal internally. Pre-marshalling to []byte caused double-encoding — the agent received a JSON string instead of the expected object. Co-Authored-By: Claude --- internal/controller/api/node/log/log_query_get.go | 15 +++++---------- internal/controller/api/node/log/log_unit_get.go | 14 ++++---------- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/internal/controller/api/node/log/log_query_get.go b/internal/controller/api/node/log/log_query_get.go index f6c7838a8..3c72fe1f6 100644 --- a/internal/controller/api/node/log/log_query_get.go +++ b/internal/controller/api/node/log/log_query_get.go @@ -32,6 +32,7 @@ import ( logProv "github.com/retr0h/osapi/internal/provider/node/log" ) + // GetNodeLog returns system log entries from a target node. func (s *Log) GetNodeLog( ctx context.Context, @@ -59,17 +60,11 @@ func (s *Log) GetNodeLog( opts.Priority = *request.Params.Priority } - data, err := json.Marshal(opts) - if err != nil { - errMsg := err.Error() - return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil - } - if job.IsBroadcastTarget(hostname) { - return s.getNodeLogBroadcast(ctx, hostname, data) + return s.getNodeLogBroadcast(ctx, hostname, opts) } - jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, data) + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQuery, opts) if err != nil { errMsg := err.Error() return gen.GetNodeLog500JSONResponse{Error: &errMsg}, nil @@ -160,14 +155,14 @@ func intPtrOrNil( func (s *Log) getNodeLogBroadcast( ctx context.Context, target string, - data []byte, + opts logProv.QueryOpts, ) (gen.GetNodeLogResponseObject, error) { jobID, responses, err := s.JobClient.QueryBroadcast( ctx, target, "node", job.OperationLogQuery, - data, + opts, ) if err != nil { errMsg := err.Error() diff --git a/internal/controller/api/node/log/log_unit_get.go b/internal/controller/api/node/log/log_unit_get.go index ac07a651f..5065f75b5 100644 --- a/internal/controller/api/node/log/log_unit_get.go +++ b/internal/controller/api/node/log/log_unit_get.go @@ -70,17 +70,11 @@ func (s *Log) GetNodeLogUnit( payload.Priority = *request.Params.Priority } - data, err := json.Marshal(payload) - if err != nil { - errMsg := err.Error() - return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil - } - if job.IsBroadcastTarget(hostname) { - return s.getNodeLogUnitBroadcast(ctx, hostname, data) + return s.getNodeLogUnitBroadcast(ctx, hostname, payload) } - jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQueryUnit, data) + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogQueryUnit, payload) if err != nil { errMsg := err.Error() return gen.GetNodeLogUnit500JSONResponse{Error: &errMsg}, nil @@ -137,14 +131,14 @@ func logEntriesFromUnitResponse( func (s *Log) getNodeLogUnitBroadcast( ctx context.Context, target string, - data []byte, + payload unitQueryPayload, ) (gen.GetNodeLogUnitResponseObject, error) { jobID, responses, err := s.JobClient.QueryBroadcast( ctx, target, "node", job.OperationLogQueryUnit, - data, + payload, ) if err != nil { errMsg := err.Error() From 5587e86f488806f7622e3850eebb88ab50009e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 20:53:12 -0700 Subject: [PATCH 15/19] feat(log): add list sources operation and fix coverage to 100% MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ListSources operation across all layers — runs `journalctl --field=SYSLOG_IDENTIFIER` to discover available log sources. New endpoint: GET /node/{hostname}/log/source. Also add missing test coverage for query params and timestamp edge cases to bring all new code to 100% statement coverage. Co-Authored-By: Claude --- cmd/client_node_log_source.go | 89 ++++ internal/agent/processor_log.go | 18 + internal/controller/api/gen/api.yaml | 79 +++ internal/controller/api/node/log/gen/api.yaml | 78 +++ .../controller/api/node/log/gen/log.gen.go | 132 ++++- .../controller/api/node/log/log_query_get.go | 11 +- .../api/node/log/log_query_get_public_test.go | 39 +- .../controller/api/node/log/log_source_get.go | 155 ++++++ .../node/log/log_source_get_public_test.go | 485 ++++++++++++++++++ .../controller/api/node/log/log_unit_get.go | 10 +- .../api/node/log/log_unit_get_public_test.go | 37 +- internal/job/types.go | 1 + internal/provider/node/log/darwin.go | 7 + .../provider/node/log/darwin_public_test.go | 19 + internal/provider/node/log/debian.go | 14 + .../provider/node/log/debian_public_test.go | 88 ++++ internal/provider/node/log/debian_query.go | 22 + internal/provider/node/log/linux.go | 7 + .../provider/node/log/linux_public_test.go | 19 + .../provider/node/log/mocks/provider.gen.go | 15 + internal/provider/node/log/types.go | 2 + pkg/sdk/client/gen/client.gen.go | 175 ++++++- pkg/sdk/client/log.go | 30 ++ pkg/sdk/client/log_public_test.go | 169 ++++++ pkg/sdk/client/log_types.go | 41 ++ pkg/sdk/client/operations.go | 1 + 26 files changed, 1720 insertions(+), 23 deletions(-) create mode 100644 cmd/client_node_log_source.go create mode 100644 internal/controller/api/node/log/log_source_get.go create mode 100644 internal/controller/api/node/log/log_source_get_public_test.go diff --git a/cmd/client_node_log_source.go b/cmd/client_node_log_source.go new file mode 100644 index 000000000..249e9dbe9 --- /dev/null +++ b/cmd/client_node_log_source.go @@ -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) +} diff --git a/internal/agent/processor_log.go b/internal/agent/processor_log.go index 37d63bf00..8478bb2a5 100644 --- a/internal/agent/processor_log.go +++ b/internal/agent/processor_log.go @@ -55,6 +55,8 @@ func processLogOperation( return processLogQuery(ctx, logProvider, logger, jobRequest) case "queryUnit": return processLogQueryUnit(ctx, logProvider, logger, jobRequest) + case "sources": + return processLogSources(ctx, logProvider, logger) default: return nil, fmt.Errorf("unsupported log operation: %s", jobRequest.Operation) } @@ -84,6 +86,22 @@ func processLogQuery( return json.Marshal(result) } +// processLogSources retrieves unique syslog identifiers from the journal. +func processLogSources( + ctx context.Context, + logProvider logProv.Provider, + logger *slog.Logger, +) (json.RawMessage, error) { + logger.Debug("executing log.ListSources") + + result, err := logProvider.ListSources(ctx) + if err != nil { + return nil, err + } + + return json.Marshal(result) +} + // processLogQueryUnit retrieves journal entries for a specific systemd unit. func processLogQueryUnit( ctx context.Context, diff --git a/internal/controller/api/gen/api.yaml b/internal/controller/api/gen/api.yaml index f3e622b6f..7a3451022 100644 --- a/internal/controller/api/gen/api.yaml +++ b/internal/controller/api/gen/api.yaml @@ -2269,6 +2269,46 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /node/{hostname}/log/source: + servers: [] + get: + summary: List log sources + description: > + List unique syslog identifiers (log sources) available in the journal on + the target node. + tags: + - Log_Management_API_log_operations + operationId: GetNodeLogSource + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: List of log sources. + content: + application/json: + schema: + $ref: '#/components/schemas/LogSourceCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error listing log sources. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /node/{hostname}/log/unit/{name}: servers: [] get: @@ -6383,6 +6423,45 @@ components: required: - hostname - status + LogSourceEntry: + type: object + description: Log source result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: + - ok + - failed + - skipped + description: The status of the operation for this host. + sources: + type: array + description: Unique syslog identifiers on this agent. + items: + type: string + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + LogSourceCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: 550e8400-e29b-41d4-a716-446655440000 + results: + type: array + items: + $ref: '#/components/schemas/LogSourceEntry' + required: + - results LogCollectionResponse: type: object properties: diff --git a/internal/controller/api/node/log/gen/api.yaml b/internal/controller/api/node/log/gen/api.yaml index b81b11306..1817683df 100644 --- a/internal/controller/api/node/log/gen/api.yaml +++ b/internal/controller/api/node/log/gen/api.yaml @@ -96,6 +96,46 @@ paths: schema: $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + /node/{hostname}/log/source: + get: + summary: List log sources + description: > + List unique syslog identifiers (log sources) available in the + journal on the target node. + tags: + - log_operations + operationId: GetNodeLogSource + security: + - BearerAuth: + - log:read + parameters: + - $ref: '#/components/parameters/Hostname' + responses: + '200': + description: List of log sources. + content: + application/json: + schema: + $ref: '#/components/schemas/LogSourceCollectionResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error listing log sources. + content: + application/json: + schema: + $ref: '../../../common/gen/api.yaml#/components/schemas/ErrorResponse' + /node/{hostname}/log/unit/{name}: get: summary: Get log entries for a systemd unit @@ -263,6 +303,44 @@ components: - hostname - status + LogSourceEntry: + type: object + description: Log source result for a single agent. + properties: + hostname: + type: string + description: The hostname of the agent. + status: + type: string + enum: [ok, failed, skipped] + description: The status of the operation for this host. + sources: + type: array + description: Unique syslog identifiers on this agent. + items: + type: string + error: + type: string + description: Error message if the agent failed. + required: + - hostname + - status + + LogSourceCollectionResponse: + type: object + properties: + job_id: + type: string + format: uuid + description: The job ID used to process this request. + example: "550e8400-e29b-41d4-a716-446655440000" + results: + type: array + items: + $ref: '#/components/schemas/LogSourceEntry' + required: + - results + # -- Collection responses -- LogCollectionResponse: diff --git a/internal/controller/api/node/log/gen/log.gen.go b/internal/controller/api/node/log/gen/log.gen.go index d359b8468..c7ee53d44 100644 --- a/internal/controller/api/node/log/gen/log.gen.go +++ b/internal/controller/api/node/log/gen/log.gen.go @@ -22,9 +22,16 @@ const ( // Defines values for LogResultEntryStatus. const ( - Failed LogResultEntryStatus = "failed" - Ok LogResultEntryStatus = "ok" - Skipped LogResultEntryStatus = "skipped" + LogResultEntryStatusFailed LogResultEntryStatus = "failed" + LogResultEntryStatusOk LogResultEntryStatus = "ok" + LogResultEntryStatusSkipped LogResultEntryStatus = "skipped" +) + +// Defines values for LogSourceEntryStatus. +const ( + LogSourceEntryStatusFailed LogSourceEntryStatus = "failed" + LogSourceEntryStatusOk LogSourceEntryStatus = "ok" + LogSourceEntryStatusSkipped LogSourceEntryStatus = "skipped" ) // ErrorResponse defines model for ErrorResponse. @@ -76,6 +83,31 @@ type LogResultEntry struct { // LogResultEntryStatus The status of the operation for this host. type LogResultEntryStatus string +// LogSourceCollectionResponse defines model for LogSourceCollectionResponse. +type LogSourceCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []LogSourceEntry `json:"results"` +} + +// LogSourceEntry Log source result for a single agent. +type LogSourceEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Sources Unique syslog identifiers on this agent. + Sources *[]string `json:"sources,omitempty"` + + // Status The status of the operation for this host. + Status LogSourceEntryStatus `json:"status"` +} + +// LogSourceEntryStatus The status of the operation for this host. +type LogSourceEntryStatus string + // Hostname defines model for Hostname. type Hostname = string @@ -111,6 +143,9 @@ type ServerInterface interface { // Get system log entries // (GET /node/{hostname}/log) GetNodeLog(ctx echo.Context, hostname Hostname, params GetNodeLogParams) error + // List log sources + // (GET /node/{hostname}/log/source) + GetNodeLogSource(ctx echo.Context, hostname Hostname) error // Get log entries for a systemd unit // (GET /node/{hostname}/log/unit/{name}) GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error @@ -162,6 +197,24 @@ func (w *ServerInterfaceWrapper) GetNodeLog(ctx echo.Context) error { return err } +// GetNodeLogSource converts echo context to params. +func (w *ServerInterfaceWrapper) GetNodeLogSource(ctx echo.Context) error { + var err error + // ------------- Path parameter "hostname" ------------- + var hostname Hostname + + err = runtime.BindStyledParameterWithOptions("simple", "hostname", ctx.Param("hostname"), &hostname, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter hostname: %s", err)) + } + + ctx.Set(BearerAuthScopes, []string{"log:read"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetNodeLogSource(ctx, hostname) + return err +} + // GetNodeLogUnit converts echo context to params. func (w *ServerInterfaceWrapper) GetNodeLogUnit(ctx echo.Context) error { var err error @@ -240,6 +293,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } router.GET(baseURL+"/node/:hostname/log", wrapper.GetNodeLog) + router.GET(baseURL+"/node/:hostname/log/source", wrapper.GetNodeLogSource) router.GET(baseURL+"/node/:hostname/log/unit/:name", wrapper.GetNodeLogUnit) } @@ -289,6 +343,50 @@ func (response GetNodeLog500JSONResponse) VisitGetNodeLogResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetNodeLogSourceRequestObject struct { + Hostname Hostname `json:"hostname"` +} + +type GetNodeLogSourceResponseObject interface { + VisitGetNodeLogSourceResponse(w http.ResponseWriter) error +} + +type GetNodeLogSource200JSONResponse LogSourceCollectionResponse + +func (response GetNodeLogSource200JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogSource401JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogSource401JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogSource403JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogSource403JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetNodeLogSource500JSONResponse externalRef0.ErrorResponse + +func (response GetNodeLogSource500JSONResponse) VisitGetNodeLogSourceResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type GetNodeLogUnitRequestObject struct { Hostname Hostname `json:"hostname"` Name UnitName `json:"name"` @@ -340,6 +438,9 @@ type StrictServerInterface interface { // Get system log entries // (GET /node/{hostname}/log) GetNodeLog(ctx context.Context, request GetNodeLogRequestObject) (GetNodeLogResponseObject, error) + // List log sources + // (GET /node/{hostname}/log/source) + GetNodeLogSource(ctx context.Context, request GetNodeLogSourceRequestObject) (GetNodeLogSourceResponseObject, error) // Get log entries for a systemd unit // (GET /node/{hostname}/log/unit/{name}) GetNodeLogUnit(ctx context.Context, request GetNodeLogUnitRequestObject) (GetNodeLogUnitResponseObject, error) @@ -383,6 +484,31 @@ func (sh *strictHandler) GetNodeLog(ctx echo.Context, hostname Hostname, params return nil } +// GetNodeLogSource operation middleware +func (sh *strictHandler) GetNodeLogSource(ctx echo.Context, hostname Hostname) error { + var request GetNodeLogSourceRequestObject + + request.Hostname = hostname + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetNodeLogSource(ctx.Request().Context(), request.(GetNodeLogSourceRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNodeLogSource") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetNodeLogSourceResponseObject); ok { + return validResponse.VisitGetNodeLogSourceResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // GetNodeLogUnit operation middleware func (sh *strictHandler) GetNodeLogUnit(ctx echo.Context, hostname Hostname, name UnitName, params GetNodeLogUnitParams) error { var request GetNodeLogUnitRequestObject diff --git a/internal/controller/api/node/log/log_query_get.go b/internal/controller/api/node/log/log_query_get.go index 3c72fe1f6..218188e38 100644 --- a/internal/controller/api/node/log/log_query_get.go +++ b/internal/controller/api/node/log/log_query_get.go @@ -32,7 +32,6 @@ import ( logProv "github.com/retr0h/osapi/internal/provider/node/log" ) - // GetNodeLog returns system log entries from a target node. func (s *Log) GetNodeLog( ctx context.Context, @@ -78,7 +77,7 @@ func (s *Log) GetNodeLog( Results: []gen.LogResultEntry{ { Hostname: resp.Hostname, - Status: gen.Skipped, + Status: gen.LogResultEntryStatusSkipped, Error: &e, }, }, @@ -93,7 +92,7 @@ func (s *Log) GetNodeLog( Results: []gen.LogResultEntry{ { Hostname: resp.Hostname, - Status: gen.Ok, + Status: gen.LogResultEntryStatusOk, Entries: &entries, }, }, @@ -176,15 +175,15 @@ func (s *Log) getNodeLogBroadcast( } switch resp.Status { case job.StatusFailed: - item.Status = gen.Failed + item.Status = gen.LogResultEntryStatusFailed e := resp.Error item.Error = &e case job.StatusSkipped: - item.Status = gen.Skipped + item.Status = gen.LogResultEntryStatusSkipped e := resp.Error item.Error = &e default: - item.Status = gen.Ok + item.Status = gen.LogResultEntryStatusOk entries := logEntriesFromResponse(resp) item.Entries = &entries } diff --git a/internal/controller/api/node/log/log_query_get_public_test.go b/internal/controller/api/node/log/log_query_get_public_test.go index 3cdf45374..8df14d26d 100644 --- a/internal/controller/api/node/log/log_query_get_public_test.go +++ b/internal/controller/api/node/log/log_query_get_public_test.go @@ -111,7 +111,7 @@ func (s *LogQueryPublicTestSuite) TestGetNodeLog() { s.True(ok) s.Require().Len(r.Results, 1) s.Equal("agent1", r.Results[0].Hostname) - s.Equal(gen.Ok, r.Results[0].Status) + s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status) s.Require().NotNil(r.Results[0].Entries) s.Len(*r.Results[0].Entries, 1) e := (*r.Results[0].Entries)[0] @@ -119,6 +119,38 @@ func (s *LogQueryPublicTestSuite) TestGetNodeLog() { s.Equal("Started OpenSSH server", *e.Message) }, }, + { + name: "success with query params", + request: gen.GetNodeLogRequestObject{ + Hostname: "server1", + Params: gen.GetNodeLogParams{ + Lines: intPtr(50), + Since: stringPtr("1 hour ago"), + Priority: stringPtr("err"), + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQuery, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogResponseObject) { + r, ok := resp.(gen.GetNodeLog200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status) + }, + }, { name: "validation error empty hostname", request: gen.GetNodeLogRequestObject{ @@ -206,7 +238,7 @@ func (s *LogQueryPublicTestSuite) TestGetNodeLog() { r, ok := resp.(gen.GetNodeLog200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) - s.Equal(gen.Skipped, r.Results[0].Status) + s.Equal(gen.LogResultEntryStatusSkipped, r.Results[0].Status) s.Require().NotNil(r.Results[0].Error) s.Equal("unsupported", *r.Results[0].Error) }, @@ -487,6 +519,9 @@ func (s *LogQueryPublicTestSuite) TestGetNodeLogRBACHTTP() { } } +func intPtr(i int) *int { return &i } +func stringPtr(s string) *string { return &s } + func TestLogQueryPublicTestSuite(t *testing.T) { suite.Run(t, new(LogQueryPublicTestSuite)) } diff --git a/internal/controller/api/node/log/log_source_get.go b/internal/controller/api/node/log/log_source_get.go new file mode 100644 index 000000000..01adedc9c --- /dev/null +++ b/internal/controller/api/node/log/log_source_get.go @@ -0,0 +1,155 @@ +// 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 log + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" +) + +// GetNodeLogSource returns unique syslog identifiers from a target node. +func (s *Log) GetNodeLogSource( + ctx context.Context, + request gen.GetNodeLogSourceRequestObject, +) (gen.GetNodeLogSourceResponseObject, error) { + if errMsg, ok := validateHostname(request.Hostname); !ok { + return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil + } + + hostname := request.Hostname + + s.logger.Debug("log list sources", + slog.String("target", hostname), + slog.Bool("broadcast", job.IsBroadcastTarget(hostname)), + ) + + if job.IsBroadcastTarget(hostname) { + return s.getNodeLogSourceBroadcast(ctx, hostname) + } + + jobID, resp, err := s.JobClient.Query(ctx, hostname, "node", job.OperationLogSources, nil) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil + } + + if resp.Status == job.StatusSkipped { + e := resp.Error + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLogSource200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogSourceEntry{ + { + Hostname: resp.Hostname, + Status: gen.LogSourceEntryStatusSkipped, + Error: &e, + }, + }, + }, nil + } + + sources := logSourcesFromResponse(resp) + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLogSource200JSONResponse{ + JobId: &jobUUID, + Results: []gen.LogSourceEntry{ + { + Hostname: resp.Hostname, + Status: gen.LogSourceEntryStatusOk, + Sources: &sources, + }, + }, + }, nil +} + +// logSourcesFromResponse extracts a []string of syslog identifiers from a job +// response. +func logSourcesFromResponse( + resp *job.Response, +) []string { + var sources []string + if resp.Data != nil { + _ = json.Unmarshal(resp.Data, &sources) + } + + if sources == nil { + sources = []string{} + } + + return sources +} + +// getNodeLogSourceBroadcast handles broadcast targets for log source listing. +func (s *Log) getNodeLogSourceBroadcast( + ctx context.Context, + target string, +) (gen.GetNodeLogSourceResponseObject, error) { + jobID, responses, err := s.JobClient.QueryBroadcast( + ctx, + target, + "node", + job.OperationLogSources, + nil, + ) + if err != nil { + errMsg := err.Error() + return gen.GetNodeLogSource500JSONResponse{Error: &errMsg}, nil + } + + var items []gen.LogSourceEntry + for host, resp := range responses { + item := gen.LogSourceEntry{ + Hostname: host, + } + + switch resp.Status { + case job.StatusFailed: + item.Status = gen.LogSourceEntryStatusFailed + e := resp.Error + item.Error = &e + case job.StatusSkipped: + item.Status = gen.LogSourceEntryStatusSkipped + e := resp.Error + item.Error = &e + default: + item.Status = gen.LogSourceEntryStatusOk + sources := logSourcesFromResponse(resp) + item.Sources = &sources + } + + items = append(items, item) + } + + jobUUID := uuid.MustParse(jobID) + + return gen.GetNodeLogSource200JSONResponse{ + JobId: &jobUUID, + Results: items, + }, nil +} diff --git a/internal/controller/api/node/log/log_source_get_public_test.go b/internal/controller/api/node/log/log_source_get_public_test.go new file mode 100644 index 000000000..f0335c24f --- /dev/null +++ b/internal/controller/api/node/log/log_source_get_public_test.go @@ -0,0 +1,485 @@ +// 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 log_test + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/authtoken" + "github.com/retr0h/osapi/internal/config" + "github.com/retr0h/osapi/internal/controller/api" + logAPI "github.com/retr0h/osapi/internal/controller/api/node/log" + "github.com/retr0h/osapi/internal/controller/api/node/log/gen" + "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/validation" +) + +type LogSourcePublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *logAPI.Log + ctx context.Context + appConfig config.Config + logger *slog.Logger +} + +func (s *LogSourcePublicTestSuite) SetupSuite() { + validation.RegisterTargetValidator(func(_ context.Context) ([]validation.AgentTarget, error) { + return []validation.AgentTarget{ + {Hostname: "server1", Labels: map[string]string{"group": "web"}}, + {Hostname: "server2"}, + }, nil + }) +} + +func (s *LogSourcePublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = logAPI.New(slog.Default(), s.mockJobClient) + s.ctx = context.Background() + s.appConfig = config.Config{} + s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (s *LogSourcePublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *LogSourcePublicTestSuite) TestGetNodeLogSource() { + tests := []struct { + name string + request gen.GetNodeLogSourceRequestObject + setupMock func() + validateFunc func(resp gen.GetNodeLogSourceResponseObject) + }{ + { + name: "success", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`["cron","nginx","sshd"]`), + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("agent1", r.Results[0].Hostname) + s.Equal(gen.LogSourceEntryStatusOk, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Sources) + s.Equal([]string{"cron", "nginx", "sshd"}, *r.Results[0].Sources) + }, + }, + { + name: "validation error empty hostname", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "", + }, + setupMock: func() {}, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource500JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "required") + }, + }, + { + name: "success with nil response data", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: nil, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("agent1", r.Results[0].Hostname) + s.Require().NotNil(r.Results[0].Sources) + s.Empty(*r.Results[0].Sources) + }, + }, + { + name: "job client error", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + _, ok := resp.(gen.GetNodeLogSource500JSONResponse) + s.True(ok) + }, + }, + { + name: "when job skipped", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "server1", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + Status: job.StatusSkipped, + Hostname: "server1", + Error: "unsupported", + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.LogSourceEntryStatusSkipped, r.Results[0].Status) + s.Require().NotNil(r.Results[0].Error) + s.Equal("unsupported", *r.Results[0].Error) + }, + }, + { + name: "broadcast success", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server1", + Data: json.RawMessage(`["nginx","sshd"]`), + }, + "server2": { + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "server2", + Data: json.RawMessage(`[]`), + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource200JSONResponse) + s.True(ok) + s.Require().NotNil(r.JobId) + s.Len(r.Results, 2) + }, + }, + { + name: "broadcast with failed and skipped hosts", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", map[string]*job.Response{ + "server1": { + Hostname: "server1", + Data: json.RawMessage(`["sshd"]`), + }, + "server2": { + Status: job.StatusFailed, + Error: "agent unreachable", + Hostname: "server2", + }, + "server3": { + Status: job.StatusSkipped, + Error: "log: operation not supported on this OS family", + Hostname: "server3", + }, + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + r, ok := resp.(gen.GetNodeLogSource200JSONResponse) + s.True(ok) + s.Len(r.Results, 3) + }, + }, + { + name: "broadcast error collecting responses", + request: gen.GetNodeLogSourceRequestObject{ + Hostname: "_all", + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryBroadcast( + gomock.Any(), + "_all", + "node", + job.OperationLogSources, + gomock.Any(), + ). + Return("", nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNodeLogSourceResponseObject) { + _, ok := resp.(gen.GetNodeLogSource500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + tt.setupMock() + + resp, err := s.handler.GetNodeLogSource(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func (s *LogSourcePublicTestSuite) TestGetNodeLogSourceHTTP() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/node/server1/log/source", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogSources, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + { + name: "when target agent not found", + path: "/node/nonexistent/log/source", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusInternalServerError, + wantContains: []string{`"error"`, "valid_target", "not found"}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + logHandler := logAPI.New(s.logger, jobMock) + strictHandler := gen.NewStrictHandler(logHandler, nil) + + a := api.New(s.appConfig, s.logger) + gen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +const rbacLogSourceTestSigningKey = "test-signing-key-for-rbac-log-source" + +func (s *LogSourcePublicTestSuite) TestGetNodeLogSourceRBACHTTP() { + tokenManager := authtoken.New(s.logger) + + tests := []struct { + name string + setupAuth func(req *http.Request) + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when no token returns 401", + setupAuth: func(_ *http.Request) { + // No auth header set + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusUnauthorized, + wantContains: []string{"Bearer token required"}, + }, + { + name: "when insufficient permissions returns 403", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogSourceTestSigningKey, + []string{"write"}, + "test-user", + []string{"docker:write"}, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(s.mockCtrl) + }, + wantCode: http.StatusForbidden, + wantContains: []string{"Insufficient permissions"}, + }, + { + name: "when valid token with log:read returns 200", + setupAuth: func(req *http.Request) { + token, err := tokenManager.Generate( + rbacLogSourceTestSigningKey, + []string{"admin"}, + "test-user", + nil, + ) + s.Require().NoError(err) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) + }, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(s.mockCtrl) + mock.EXPECT(). + Query(gomock.Any(), "server1", "node", job.OperationLogSources, gomock.Any()). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"job_id"`, `"results"`}, + }, + } + + for _, tc := range tests { + s.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + appConfig := config.Config{ + Controller: config.Controller{ + API: config.APIServer{ + Security: config.ServerSecurity{ + SigningKey: rbacLogSourceTestSigningKey, + }, + }, + }, + } + + server := api.New(appConfig, s.logger) + handlers := logAPI.Handler( + s.logger, + jobMock, + appConfig.Controller.API.Security.SigningKey, + nil, + ) + server.RegisterHandlers(handlers) + + req := httptest.NewRequest( + http.MethodGet, + "/node/server1/log/source", + nil, + ) + tc.setupAuth(req) + rec := httptest.NewRecorder() + + server.Echo.ServeHTTP(rec, req) + + s.Equal(tc.wantCode, rec.Code) + for _, str := range tc.wantContains { + s.Contains(rec.Body.String(), str) + } + }) + } +} + +func TestLogSourcePublicTestSuite(t *testing.T) { + suite.Run(t, new(LogSourcePublicTestSuite)) +} diff --git a/internal/controller/api/node/log/log_unit_get.go b/internal/controller/api/node/log/log_unit_get.go index 5065f75b5..7b99dd597 100644 --- a/internal/controller/api/node/log/log_unit_get.go +++ b/internal/controller/api/node/log/log_unit_get.go @@ -88,7 +88,7 @@ func (s *Log) GetNodeLogUnit( Results: []gen.LogResultEntry{ { Hostname: resp.Hostname, - Status: gen.Skipped, + Status: gen.LogResultEntryStatusSkipped, Error: &e, }, }, @@ -103,7 +103,7 @@ func (s *Log) GetNodeLogUnit( Results: []gen.LogResultEntry{ { Hostname: resp.Hostname, - Status: gen.Ok, + Status: gen.LogResultEntryStatusOk, Entries: &entries, }, }, @@ -152,15 +152,15 @@ func (s *Log) getNodeLogUnitBroadcast( } switch resp.Status { case job.StatusFailed: - item.Status = gen.Failed + item.Status = gen.LogResultEntryStatusFailed e := resp.Error item.Error = &e case job.StatusSkipped: - item.Status = gen.Skipped + item.Status = gen.LogResultEntryStatusSkipped e := resp.Error item.Error = &e default: - item.Status = gen.Ok + item.Status = gen.LogResultEntryStatusOk entries := logEntriesFromUnitResponse(resp) item.Entries = &entries } diff --git a/internal/controller/api/node/log/log_unit_get_public_test.go b/internal/controller/api/node/log/log_unit_get_public_test.go index 6e823e814..b0831bc5f 100644 --- a/internal/controller/api/node/log/log_unit_get_public_test.go +++ b/internal/controller/api/node/log/log_unit_get_public_test.go @@ -101,13 +101,46 @@ func (s *LogUnitPublicTestSuite) TestGetNodeLogUnit() { s.True(ok) s.Require().Len(r.Results, 1) s.Equal("agent1", r.Results[0].Hostname) - s.Equal(gen.Ok, r.Results[0].Status) + s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status) s.Require().NotNil(r.Results[0].Entries) s.Len(*r.Results[0].Entries, 1) e := (*r.Results[0].Entries)[0] s.Equal("sshd.service", *e.Unit) }, }, + { + name: "success with query params", + request: gen.GetNodeLogUnitRequestObject{ + Hostname: "server1", + Name: "nginx.service", + Params: gen.GetNodeLogUnitParams{ + Lines: intPtr(25), + Since: stringPtr("2026-03-31"), + Priority: stringPtr("warning"), + }, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + Query( + gomock.Any(), + "server1", + "node", + job.OperationLogQueryUnit, + gomock.Any(), + ). + Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ + JobID: "550e8400-e29b-41d4-a716-446655440000", + Hostname: "agent1", + Data: json.RawMessage(`[]`), + }, nil) + }, + validateFunc: func(resp gen.GetNodeLogUnitResponseObject) { + r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal(gen.LogResultEntryStatusOk, r.Results[0].Status) + }, + }, { name: "validation error empty hostname", request: gen.GetNodeLogUnitRequestObject{ @@ -198,7 +231,7 @@ func (s *LogUnitPublicTestSuite) TestGetNodeLogUnit() { r, ok := resp.(gen.GetNodeLogUnit200JSONResponse) s.True(ok) s.Require().Len(r.Results, 1) - s.Equal(gen.Skipped, r.Results[0].Status) + s.Equal(gen.LogResultEntryStatusSkipped, r.Results[0].Status) s.Require().NotNil(r.Results[0].Error) s.Equal("unsupported", *r.Results[0].Error) }, diff --git a/internal/job/types.go b/internal/job/types.go index 3541cc502..a37825689 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -223,6 +223,7 @@ const ( const ( OperationLogQuery = client.OpLogQuery OperationLogQueryUnit = client.OpLogQueryUnit + OperationLogSources = client.OpLogSources ) // Operation represents an operation in the new hierarchical format diff --git a/internal/provider/node/log/darwin.go b/internal/provider/node/log/darwin.go index f5deabc77..145c42287 100644 --- a/internal/provider/node/log/darwin.go +++ b/internal/provider/node/log/darwin.go @@ -52,3 +52,10 @@ func (d *Darwin) QueryUnit( ) ([]Entry, error) { return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) } + +// ListSources returns ErrUnsupported on Darwin. +func (d *Darwin) ListSources( + _ context.Context, +) ([]string, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/log/darwin_public_test.go b/internal/provider/node/log/darwin_public_test.go index c0d6026f0..c23077780 100644 --- a/internal/provider/node/log/darwin_public_test.go +++ b/internal/provider/node/log/darwin_public_test.go @@ -82,6 +82,25 @@ func (suite *DarwinPublicTestSuite) TestQueryUnit() { } } +func (suite *DarwinPublicTestSuite) TestListSources() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.ListSources(context.Background()) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + // 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 TestDarwinPublicTestSuite(t *testing.T) { diff --git a/internal/provider/node/log/debian.go b/internal/provider/node/log/debian.go index 3671064f0..acc2e7675 100644 --- a/internal/provider/node/log/debian.go +++ b/internal/provider/node/log/debian.go @@ -88,3 +88,17 @@ func (d *Debian) QueryUnit( return parseJournalOutput(output, d.logger), nil } + +// ListSources returns unique syslog identifiers from the journal. +func (d *Debian) ListSources( + _ context.Context, +) ([]string, error) { + d.logger.Debug("executing log.ListSources") + + output, err := d.execManager.RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}) + if err != nil { + return nil, fmt.Errorf("log: list sources: %w", err) + } + + return parseSources(output), nil +} diff --git a/internal/provider/node/log/debian_public_test.go b/internal/provider/node/log/debian_public_test.go index c2e8afde5..6ee8cdab7 100644 --- a/internal/provider/node/log/debian_public_test.go +++ b/internal/provider/node/log/debian_public_test.go @@ -156,6 +156,32 @@ func (suite *DebianPublicTestSuite) TestQuery() { suite.Equal("nginx", result[0].Unit) }, }, + { + name: "when entry has empty timestamp", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return(`{"__REALTIME_TIMESTAMP":"","SYSLOG_IDENTIFIER":"test","PRIORITY":"6","MESSAGE":"hello"}`, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + suite.Equal("", result[0].Timestamp) + }, + }, + { + name: "when entry has non-numeric timestamp", + opts: oslog.QueryOpts{}, + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", gomock.Any()). + Return(`{"__REALTIME_TIMESTAMP":"not-a-number","SYSLOG_IDENTIFIER":"test","PRIORITY":"6","MESSAGE":"hello"}`, nil) + }, + validateFunc: func(result []oslog.Entry) { + suite.Len(result, 1) + suite.Equal("not-a-number", result[0].Timestamp) + }, + }, } for _, tc := range tests { @@ -257,6 +283,68 @@ func (suite *DebianPublicTestSuite) TestQueryUnit() { } } +func (suite *DebianPublicTestSuite) TestListSources() { + tests := []struct { + name string + setupMock func() + wantErr bool + wantErrMsg string + validateFunc func(result []string) + }{ + { + name: "when sources returned sorted list", + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}). + Return("sshd\nnginx\ncron\n", nil) + }, + validateFunc: func(result []string) { + suite.Equal([]string{"cron", "nginx", "sshd"}, result) + }, + }, + { + name: "when exec errors returns error", + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}). + Return("", errors.New("journalctl not found")) + }, + wantErr: true, + wantErrMsg: "log: list sources: journalctl not found", + }, + { + name: "when empty output returns nil", + setupMock: func() { + suite.mockManager.EXPECT(). + RunCmd("journalctl", []string{"--field=SYSLOG_IDENTIFIER"}). + Return("", nil) + }, + validateFunc: func(result []string) { + suite.Nil(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.setupMock() + + got, err := suite.provider.ListSources(context.Background()) + + if tc.wantErr { + suite.Error(err) + suite.Contains(err.Error(), tc.wantErrMsg) + suite.Nil(got) + + return + } + + suite.NoError(err) + 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 TestDebianPublicTestSuite(t *testing.T) { diff --git a/internal/provider/node/log/debian_query.go b/internal/provider/node/log/debian_query.go index a28cfa792..e87b6eff9 100644 --- a/internal/provider/node/log/debian_query.go +++ b/internal/provider/node/log/debian_query.go @@ -23,6 +23,7 @@ package log import ( "encoding/json" "log/slog" + "sort" "strconv" "strings" "time" @@ -161,6 +162,27 @@ func journalEntryToEntry( } } +// parseSources parses newline-delimited syslog identifier output from +// journalctl --field=SYSLOG_IDENTIFIER and returns a sorted, deduplicated +// list of non-empty identifiers. +func parseSources( + output string, +) []string { + lines := strings.Split(strings.TrimSpace(output), "\n") + var sources []string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + sources = append(sources, line) + } + } + + sort.Strings(sources) + + return sources +} + // parseTimestamp converts a journald microsecond timestamp string to RFC3339Nano. // Returns the original string if parsing fails. func parseTimestamp( diff --git a/internal/provider/node/log/linux.go b/internal/provider/node/log/linux.go index fc945a043..2f359421c 100644 --- a/internal/provider/node/log/linux.go +++ b/internal/provider/node/log/linux.go @@ -52,3 +52,10 @@ func (l *Linux) QueryUnit( ) ([]Entry, error) { return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) } + +// ListSources returns ErrUnsupported on generic Linux. +func (l *Linux) ListSources( + _ context.Context, +) ([]string, error) { + return nil, fmt.Errorf("log: %w", provider.ErrUnsupported) +} diff --git a/internal/provider/node/log/linux_public_test.go b/internal/provider/node/log/linux_public_test.go index a6a5d5bf5..bbba3339c 100644 --- a/internal/provider/node/log/linux_public_test.go +++ b/internal/provider/node/log/linux_public_test.go @@ -82,6 +82,25 @@ func (suite *LinuxPublicTestSuite) TestQueryUnit() { } } +func (suite *LinuxPublicTestSuite) TestListSources() { + tests := []struct { + name string + }{ + { + name: "returns not implemented error", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + got, err := suite.provider.ListSources(context.Background()) + + suite.Nil(got) + suite.ErrorIs(err, provider.ErrUnsupported) + }) + } +} + // 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 TestLinuxPublicTestSuite(t *testing.T) { diff --git a/internal/provider/node/log/mocks/provider.gen.go b/internal/provider/node/log/mocks/provider.gen.go index 08f5de25c..b6afbc46e 100644 --- a/internal/provider/node/log/mocks/provider.gen.go +++ b/internal/provider/node/log/mocks/provider.gen.go @@ -35,6 +35,21 @@ func (m *MockProvider) EXPECT() *MockProviderMockRecorder { return m.recorder } +// ListSources mocks base method. +func (m *MockProvider) ListSources(ctx context.Context) ([]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListSources", ctx) + ret0, _ := ret[0].([]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListSources indicates an expected call of ListSources. +func (mr *MockProviderMockRecorder) ListSources(ctx interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListSources", reflect.TypeOf((*MockProvider)(nil).ListSources), ctx) +} + // Query mocks base method. func (m *MockProvider) Query(ctx context.Context, opts log.QueryOpts) ([]log.Entry, error) { m.ctrl.T.Helper() diff --git a/internal/provider/node/log/types.go b/internal/provider/node/log/types.go index adc99d903..3c678a3dd 100644 --- a/internal/provider/node/log/types.go +++ b/internal/provider/node/log/types.go @@ -31,6 +31,8 @@ type Provider interface { Query(ctx context.Context, opts QueryOpts) ([]Entry, error) // QueryUnit returns journal entries for a specific systemd unit. QueryUnit(ctx context.Context, unit string, opts QueryOpts) ([]Entry, error) + // ListSources returns unique syslog identifiers from the journal. + ListSources(ctx context.Context) ([]string, error) } // QueryOpts contains optional filters for log queries. diff --git a/pkg/sdk/client/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go index 811bb1c53..3bc9b1d91 100644 --- a/pkg/sdk/client/gen/client.gen.go +++ b/pkg/sdk/client/gen/client.gen.go @@ -209,6 +209,13 @@ const ( LogResultEntryStatusSkipped LogResultEntryStatus = "skipped" ) +// Defines values for LogSourceEntryStatus. +const ( + LogSourceEntryStatusFailed LogSourceEntryStatus = "failed" + LogSourceEntryStatusOk LogSourceEntryStatus = "ok" + LogSourceEntryStatusSkipped LogSourceEntryStatus = "skipped" +) + // Defines values for MemoryResultItemStatus. const ( MemoryResultItemStatusFailed MemoryResultItemStatus = "failed" @@ -381,11 +388,11 @@ const ( // Defines values for GetJobParamsStatus. const ( - Completed GetJobParamsStatus = "completed" - Failed GetJobParamsStatus = "failed" - PartialFailure GetJobParamsStatus = "partial_failure" - Processing GetJobParamsStatus = "processing" - Submitted GetJobParamsStatus = "submitted" + GetJobParamsStatusCompleted GetJobParamsStatus = "completed" + GetJobParamsStatusFailed GetJobParamsStatus = "failed" + GetJobParamsStatusPartialFailure GetJobParamsStatus = "partial_failure" + GetJobParamsStatusProcessing GetJobParamsStatus = "processing" + GetJobParamsStatusSubmitted GetJobParamsStatus = "submitted" ) // Defines values for GetNodeContainerDockerParamsState. @@ -1781,6 +1788,31 @@ type LogResultEntry struct { // LogResultEntryStatus The status of the operation for this host. type LogResultEntryStatus string +// LogSourceCollectionResponse defines model for LogSourceCollectionResponse. +type LogSourceCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []LogSourceEntry `json:"results"` +} + +// LogSourceEntry Log source result for a single agent. +type LogSourceEntry struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Sources Unique syslog identifiers on this agent. + Sources *[]string `json:"sources,omitempty"` + + // Status The status of the operation for this host. + Status LogSourceEntryStatus `json:"status"` +} + +// LogSourceEntryStatus The status of the operation for this host. +type LogSourceEntryStatus string + // MemoryCollectionResponse defines model for MemoryCollectionResponse. type MemoryCollectionResponse struct { // JobId The job ID used to process this request. @@ -3200,6 +3232,9 @@ type ClientInterface interface { // GetNodeLog request GetNodeLog(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeLogSource request + GetNodeLogSource(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetNodeLogUnit request GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -4040,6 +4075,18 @@ func (c *Client) GetNodeLog(ctx context.Context, hostname Hostname, params *GetN return c.Client.Do(req) } +func (c *Client) GetNodeLogSource(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeLogSourceRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetNodeLogUnit(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetNodeLogUnitRequest(c.Server, hostname, name, params) if err != nil { @@ -6634,6 +6681,40 @@ func NewGetNodeLogRequest(server string, hostname Hostname, params *GetNodeLogPa return req, nil } +// NewGetNodeLogSourceRequest generates requests for GetNodeLogSource +func NewGetNodeLogSourceRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/log/source", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetNodeLogUnitRequest generates requests for GetNodeLogUnit func NewGetNodeLogUnitRequest(server string, hostname Hostname, name UnitName, params *GetNodeLogUnitParams) (*http.Request, error) { var err error @@ -8596,6 +8677,9 @@ type ClientWithResponsesInterface interface { // GetNodeLogWithResponse request GetNodeLogWithResponse(ctx context.Context, hostname Hostname, params *GetNodeLogParams, reqEditors ...RequestEditorFn) (*GetNodeLogResponse, error) + // GetNodeLogSourceWithResponse request + GetNodeLogSourceWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLogSourceResponse, error) + // GetNodeLogUnitWithResponse request GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error) @@ -9900,6 +9984,31 @@ func (r GetNodeLogResponse) StatusCode() int { return 0 } +type GetNodeLogSourceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *LogSourceCollectionResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeLogSourceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeLogSourceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetNodeLogUnitResponse struct { Body []byte HTTPResponse *http.Response @@ -11458,6 +11567,15 @@ func (c *ClientWithResponses) GetNodeLogWithResponse(ctx context.Context, hostna return ParseGetNodeLogResponse(rsp) } +// GetNodeLogSourceWithResponse request returning *GetNodeLogSourceResponse +func (c *ClientWithResponses) GetNodeLogSourceWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLogSourceResponse, error) { + rsp, err := c.GetNodeLogSource(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeLogSourceResponse(rsp) +} + // GetNodeLogUnitWithResponse request returning *GetNodeLogUnitResponse func (c *ClientWithResponses) GetNodeLogUnitWithResponse(ctx context.Context, hostname Hostname, name UnitName, params *GetNodeLogUnitParams, reqEditors ...RequestEditorFn) (*GetNodeLogUnitResponse, error) { rsp, err := c.GetNodeLogUnit(ctx, hostname, name, params, reqEditors...) @@ -14335,6 +14453,53 @@ func ParseGetNodeLogResponse(rsp *http.Response) (*GetNodeLogResponse, error) { return response, nil } +// ParseGetNodeLogSourceResponse parses an HTTP response from a GetNodeLogSourceWithResponse call +func ParseGetNodeLogSourceResponse(rsp *http.Response) (*GetNodeLogSourceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeLogSourceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest LogSourceCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseGetNodeLogUnitResponse parses an HTTP response from a GetNodeLogUnitWithResponse call func ParseGetNodeLogUnitResponse(rsp *http.Response) (*GetNodeLogUnitResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/pkg/sdk/client/log.go b/pkg/sdk/client/log.go index c5e858113..ead63d075 100644 --- a/pkg/sdk/client/log.go +++ b/pkg/sdk/client/log.go @@ -68,6 +68,36 @@ func (s *LogService) Query( return NewResponse(logCollectionFromGen(resp.JSON200), resp.Body), nil } +// Sources returns unique syslog identifiers available in the journal on the +// target host. +func (s *LogService) Sources( + ctx context.Context, + hostname string, +) (*Response[Collection[LogSourceResult]], error) { + resp, err := s.client.GetNodeLogSourceWithResponse(ctx, hostname) + if err != nil { + return nil, fmt.Errorf("log sources: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(logSourceCollectionFromGen(resp.JSON200), resp.Body), nil +} + // QueryUnit returns journal log entries for a specific systemd unit on the // target host. func (s *LogService) QueryUnit( diff --git a/pkg/sdk/client/log_public_test.go b/pkg/sdk/client/log_public_test.go index a36e0f83e..bcb7ccf66 100644 --- a/pkg/sdk/client/log_public_test.go +++ b/pkg/sdk/client/log_public_test.go @@ -426,6 +426,175 @@ func (suite *LogPublicTestSuite) TestQueryUnit() { } } +func (suite *LogPublicTestSuite) TestSources() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*client.Response[client.Collection[client.LogSourceResult]], error) + }{ + { + name: "when querying sources returns result collection", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"agent1","status":"ok","sources":["cron","nginx","sshd"]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("agent1", resp.Data.Results[0].Hostname) + suite.Equal("ok", resp.Data.Results[0].Status) + suite.Equal([]string{"cron", "nginx", "sshd"}, resp.Data.Results[0].Sources) + }, + }, + { + name: "when broadcast returns multiple results", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"server1","status":"ok","sources":["sshd"]},{"hostname":"server2","status":"ok","sources":[]}]}`, + ), + ) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 2) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when server returns 500 returns ServerError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"internal error"}`)) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "log sources") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func( + resp *client.Response[client.Collection[client.LogSourceResult]], + err error, + ) { + suite.Error(err) + suite.Nil(resp) + + var target *client.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := client.New( + serverURL, + "test-token", + client.WithLogger(slog.Default()), + ) + + resp, err := sut.Log.Sources(suite.ctx, "_any") + tc.validateFunc(resp, err) + }) + } +} + func TestLogPublicTestSuite(t *testing.T) { suite.Run(t, new(LogPublicTestSuite)) } diff --git a/pkg/sdk/client/log_types.go b/pkg/sdk/client/log_types.go index 2aade0c8b..63abccf12 100644 --- a/pkg/sdk/client/log_types.go +++ b/pkg/sdk/client/log_types.go @@ -42,6 +42,14 @@ type LogEntry struct { Hostname string `json:"hostname,omitempty"` } +// LogSourceResult represents the result of a log sources query for one host. +type LogSourceResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Sources []string `json:"sources,omitempty"` + Error string `json:"error,omitempty"` +} + // LogQueryOpts contains options for log query operations. type LogQueryOpts struct { // Lines is the maximum number of log lines to return. @@ -102,3 +110,36 @@ func logEntryInfoFromGen( Hostname: derefString(e.Hostname), } } + +// logSourceCollectionFromGen converts a gen.LogSourceCollectionResponse to a +// Collection[LogSourceResult]. +func logSourceCollectionFromGen( + g *gen.LogSourceCollectionResponse, +) Collection[LogSourceResult] { + results := make([]LogSourceResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, logSourceResultFromGen(r)) + } + + return Collection[LogSourceResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// logSourceResultFromGen converts a gen.LogSourceEntry to a LogSourceResult. +func logSourceResultFromGen( + r gen.LogSourceEntry, +) LogSourceResult { + result := LogSourceResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + } + + if r.Sources != nil { + result.Sources = *r.Sources + } + + return result +} diff --git a/pkg/sdk/client/operations.go b/pkg/sdk/client/operations.go index 24eed5256..1d05f0837 100644 --- a/pkg/sdk/client/operations.go +++ b/pkg/sdk/client/operations.go @@ -159,6 +159,7 @@ const ( const ( OpLogQuery JobOperation = "node.log.query" OpLogQueryUnit JobOperation = "node.log.queryUnit" + OpLogSources JobOperation = "node.log.sources" ) // Target constants for job routing. From 69b5a1444147f524998fe3565dd6e9427b36ed95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 21:02:30 -0700 Subject: [PATCH 16/19] test: fix coverage gaps in processor_log and package handlers - Add TestProcessLogSources and query unmarshal error test - Add size field to package handler test data to cover Size > 0 branch Co-Authored-By: Claude --- internal/agent/processor_log_public_test.go | 85 +++++++++++++++++++ .../node/package/package_get_public_test.go | 11 ++- .../package/package_list_get_public_test.go | 2 +- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/internal/agent/processor_log_public_test.go b/internal/agent/processor_log_public_test.go index 89d588517..1527164ad 100644 --- a/internal/agent/processor_log_public_test.go +++ b/internal/agent/processor_log_public_test.go @@ -340,6 +340,91 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() { } } +func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnmarshalError() { + m := logMocks.NewMockProvider(s.mockCtrl) + processor := s.newProcessor(m) + + result, err := processor(job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + Data: json.RawMessage(`invalid json`), + }) + + s.Error(err) + s.Contains(err.Error(), "unmarshal log query data") + s.Nil(result) +} + +func (s *ProcessorLogPublicTestSuite) TestProcessLogSources() { + tests := []struct { + name string + jobRequest job.Request + setupMock func() log.Provider + expectError bool + errorMsg string + validate func(json.RawMessage) + }{ + { + name: "sources success", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.sources", + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + ListSources(gomock.Any()). + Return([]string{"nginx", "sshd", "systemd"}, nil) + return m + }, + validate: func(result json.RawMessage) { + var sources []string + err := json.Unmarshal(result, &sources) + s.NoError(err) + s.Equal([]string{"nginx", "sshd", "systemd"}, sources) + }, + }, + { + name: "sources provider error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.sources", + }, + setupMock: func() log.Provider { + m := logMocks.NewMockProvider(s.mockCtrl) + m.EXPECT(). + ListSources(gomock.Any()). + Return(nil, errors.New("journalctl failed")) + return m + }, + expectError: true, + errorMsg: "journalctl failed", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + processor := s.newProcessor(tt.setupMock()) + result, err := processor(tt.jobRequest) + + if tt.expectError { + s.Error(err) + s.Contains(err.Error(), tt.errorMsg) + s.Nil(result) + } else { + s.NoError(err) + s.NotNil(result) + if tt.validate != nil { + tt.validate(result) + } + } + }) + } +} + func TestProcessorLogPublicTestSuite(t *testing.T) { suite.Run(t, new(ProcessorLogPublicTestSuite)) } diff --git a/internal/controller/api/node/package/package_get_public_test.go b/internal/controller/api/node/package/package_get_public_test.go index 9414a6f52..60c593331 100644 --- a/internal/controller/api/node/package/package_get_public_test.go +++ b/internal/controller/api/node/package/package_get_public_test.go @@ -102,7 +102,7 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() { Return("550e8400-e29b-41d4-a716-446655440000", &job.Response{ Hostname: "agent1", Data: json.RawMessage( - `{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool"}`, + `{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool","size":1024}`, ), }, nil) }, @@ -114,8 +114,11 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() { s.Equal("agent1", r.Results[0].Hostname) s.Require().NotNil(r.Results[0].Packages) s.Require().Len(*r.Results[0].Packages, 1) - s.Equal("curl", *(*r.Results[0].Packages)[0].Name) - s.Equal("7.68.0", *(*r.Results[0].Packages)[0].Version) + pkg := (*r.Results[0].Packages)[0] + s.Equal("curl", *pkg.Name) + s.Equal("7.68.0", *pkg.Version) + s.Require().NotNil(pkg.Size) + s.Equal(int64(1024), *pkg.Size) }, }, { @@ -256,7 +259,7 @@ func (s *PackageGetPublicTestSuite) TestGetNodePackageByName() { Hostname: "server1", Status: job.StatusCompleted, Data: json.RawMessage( - `{"name":"curl","version":"7.68.0","status":"installed"}`, + `{"name":"curl","version":"7.68.0","status":"installed","description":"curl","size":2048}`, ), }, "server2": { diff --git a/internal/controller/api/node/package/package_list_get_public_test.go b/internal/controller/api/node/package/package_list_get_public_test.go index d22e4fb01..4b58e3aae 100644 --- a/internal/controller/api/node/package/package_list_get_public_test.go +++ b/internal/controller/api/node/package/package_list_get_public_test.go @@ -102,7 +102,7 @@ func (s *PackageListGetPublicTestSuite) TestGetNodePackage() { JobID: "550e8400-e29b-41d4-a716-446655440000", Hostname: "agent1", Data: json.RawMessage( - `[{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool for transferring data"}]`, + `[{"name":"curl","version":"7.68.0","status":"installed","description":"command line tool for transferring data","size":512}]`, ), }, nil) }, From 05e1e156bef887cfda8663f330bf68469a70748e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 21:03:56 -0700 Subject: [PATCH 17/19] refactor(log): move unmarshal error case into TestProcessLogQuery table Follow project convention: one suite method per function, all scenarios as rows in one table. Co-Authored-By: Claude --- internal/agent/processor_log_public_test.go | 30 ++++++++++----------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/internal/agent/processor_log_public_test.go b/internal/agent/processor_log_public_test.go index 1527164ad..b61e1a342 100644 --- a/internal/agent/processor_log_public_test.go +++ b/internal/agent/processor_log_public_test.go @@ -206,6 +206,20 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQuery() { s.Equal("err", entries[0].Priority) }, }, + { + name: "query unmarshal error", + jobRequest: job.Request{ + Type: job.TypeQuery, + Category: "node", + Operation: "log.query", + Data: json.RawMessage(`invalid json`), + }, + setupMock: func() log.Provider { + return logMocks.NewMockProvider(s.mockCtrl) + }, + expectError: true, + errorMsg: "unmarshal log query data", + }, { name: "query provider error", jobRequest: job.Request{ @@ -340,22 +354,6 @@ func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnit() { } } -func (s *ProcessorLogPublicTestSuite) TestProcessLogQueryUnmarshalError() { - m := logMocks.NewMockProvider(s.mockCtrl) - processor := s.newProcessor(m) - - result, err := processor(job.Request{ - Type: job.TypeQuery, - Category: "node", - Operation: "log.query", - Data: json.RawMessage(`invalid json`), - }) - - s.Error(err) - s.Contains(err.Error(), "unmarshal log query data") - s.Nil(result) -} - func (s *ProcessorLogPublicTestSuite) TestProcessLogSources() { tests := []struct { name string From 67d697e30e506cc8f695e0b3614fdeb4ef2b4496 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 21:05:30 -0700 Subject: [PATCH 18/19] docs: add generated API docs for log source endpoint Co-Authored-By: Claude --- docs/docs/gen/api/get-node-log-source.api.mdx | 520 ++++++++++++++++++ docs/docs/gen/api/sidebar.ts | 6 + 2 files changed, 526 insertions(+) create mode 100644 docs/docs/gen/api/get-node-log-source.api.mdx diff --git a/docs/docs/gen/api/get-node-log-source.api.mdx b/docs/docs/gen/api/get-node-log-source.api.mdx new file mode 100644 index 000000000..4014cf53b --- /dev/null +++ b/docs/docs/gen/api/get-node-log-source.api.mdx @@ -0,0 +1,520 @@ +--- +id: get-node-log-source +title: "List log sources" +description: "List unique syslog identifiers (log sources) available in the journal on the target node." +sidebar_label: "List log sources" +hide_title: true +hide_table_of_contents: true +api: eJztV0tv4zYQ/ivEnBKATpSts20F9JC2u4sUabHYTdBDahi0OJYZU6SWHHnjGvrvxVB+aG0V7aEBetiTRXme33zDGW1AYyyCqcl4BzncmUiiceZTgyKuo/WlMBodmbnBEMUZv4i+CQXGc6FWylg1syiME7RA8eSb4JQVvjuSCiWScF7jxR8OJJAqI+SPcOfL6a/KqRIrdDS9eX87tb6c+hqD4kAiTCTsT7cacniH9JvXeOfLj8k9SIhYNMHQGvLHDfyIKmC4aWjBDqwv84BKw6SdSKhVUBUShphEnaoQclj4SOlRguHUa0ULkBDwU2MCasgpNCiP8LnvUlIlOhI7C1IEjBhWqEXwDRlXipWyDYqzqXJrKabK2nMpfBBWzdCKiBYL8kGcLXGdJ9HzDqDnkVe1GRVeY4luhM8U1KhDbQMrZY1WxLHvgpSVcT9cyfTPtIMbWgmxWGClWIfWNctHCsaVIKEy7g5dyShdtYxNwFh7FzF5eJVl/DNACT8XvdJfgITCO0JHLK/q2poi1eryKbLS5jQEP3vCgkBCHbiyZDqXT342NXoo1LkPlSLIoWmMhpNCJLrNxO3PoomoBXlRB19gjIIWJgpGCCNxpPisqtqy7evrDL8bZ9kIX30/G42v9Hikvr16PRqPX7++vh6PsyzLGL+AsbEUe1GpENSamUJYxaGsjjDbYyU6W2Lug1AiGlda7OhzcYLFnpEDaJxmv5Pm2nC3bY1y+UlRE4esoGsq7g+/ZICVscjIxqWpa9TcdKduOmM7J/umTAklpDmOzm1HjgHUvrT68LfXS7o3TDzgcwz3NpG2lYAh+PDPSL1hMVFhjKpEYXpQiS7/i2Tt0PWP/Ythi+REAhlKFNpfQG8chTW0x8o76gyp/OQtN77x7sO261i/lTDOrk777sGphhY+mD9Ri5G4eX8rlrgWe2f/WQv+SyRvRO+840PSFbRQJHxRNCEwoP2We5tA5vYMSMHgaseoRBmNpIwdpOqRc60NPyortjpCzXxDhyAG3eoG2bVD+uzDUpCp0DcdW/mO7fk1jrDEMNhpXZKs8IWT6yzj4u3KnJh2UthvTgv71oeZ0RqdGIlbF5v53BSGCVljqEyMaQB+re7/v7rXQ+Oyu3CsiWkPeJmh+bWmL1TTVkKFtPC8cfIuJbutMIdLXmIvN7vR0F5aX17GwyYaVt12OTmspR+5mF29+svpPv4FUQ3bXY3PsyQEcvvwdrf+/PL7fRpRxs19Ut9Gf5OG2GGN5gkBEjiQDoiri+wirTO1j1SpxLDt7pt2uh43j0HcHJj60p8EHRiEz3RZW2UcB9wEyzF02D8CS4OEvDeYrWfGbQswkWlms+hmM1MRH4JtW379qcGw7sqyUsFwYOkTQJvIzxryubLxeMvvZ3/2YTtvz8UL7/6DSOwWKcdrVJKGHEDCEtf9T5h20kpYoNIYUn7d3zdFgTX1FE/uHP4C2BP+3Zt7kKC+ZOkRK5P1waA2m07i3i/Rte0+RuIzB9i2fwGZrQAf +sidebar_class_name: "get api-method" +info_path: gen/api/agent-management-api +custom_edit_url: null +--- + +import ApiTabs from "@theme/ApiTabs"; +import DiscriminatorTabs from "@theme/DiscriminatorTabs"; +import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint"; +import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes"; +import MimeTabs from "@theme/MimeTabs"; +import ParamsItem from "@theme/ParamsItem"; +import ResponseSamples from "@theme/ResponseSamples"; +import SchemaItem from "@theme/SchemaItem"; +import SchemaTabs from "@theme/SchemaTabs"; +import Heading from "@theme/Heading"; +import OperationTabs from "@theme/OperationTabs"; +import TabItem from "@theme/TabItem"; + + + + + + + + + + +List unique syslog identifiers (log sources) available in the journal on the target node. + + + + + +
+ +

+ Path Parameters +

+
+
    + + + +
+
+
+
+ + +
+ + + List of log sources. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + +
    + + + + results + + object[] + + + + required + + +
    +
  • +
    + Array [ +
    +
  • + + + + + + + +
  • +
    + ] +
    +
  • +
    +
    +
    +
+
+
+ + + + +
+
+
+
+
+
+ + + Unauthorized - API key required + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Forbidden - Insufficient permissions + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+ + + Error listing log sources. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
+
+
+
+ \ No newline at end of file diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts index 1bd0c6c44..01f9466fa 100644 --- a/docs/docs/gen/api/sidebar.ts +++ b/docs/docs/gen/api/sidebar.ts @@ -422,6 +422,12 @@ const sidebar: SidebarsConfig = { label: "Get system log entries", className: "api-method get", }, + { + type: "doc", + id: "gen/api/get-node-log-source", + label: "List log sources", + className: "api-method get", + }, { type: "doc", id: "gen/api/get-node-log-unit", From 3a275cd62846599054899c4630b9b452aee9aeee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 31 Mar 2026 21:10:14 -0700 Subject: [PATCH 19/19] docs: add missing sources operation to docs, SDK example, and cross-references The ListSources operation was added to code but docs were not updated: - Add CLI doc for source subcommand - Add /log/source to api-guidelines endpoint table - Add Sources to feature page operations table and CLI examples - Add Sources method and LogSourceResult to SDK doc - Add Sources demo to SDK example - Update features landing page description Co-Authored-By: Claude --- .../sidebar/architecture/api-guidelines.md | 1 + docs/docs/sidebar/features/features.md | 2 +- docs/docs/sidebar/features/log-management.md | 16 ++++-- .../docs/sidebar/sdk/client/operations/log.md | 26 +++++++-- .../usage/cli/client/node/log/source.md | 53 +++++++++++++++++++ examples/sdk/client/log.go | 21 ++++++++ 6 files changed, 111 insertions(+), 8 deletions(-) create mode 100644 docs/docs/sidebar/usage/cli/client/node/log/source.md diff --git a/docs/docs/sidebar/architecture/api-guidelines.md b/docs/docs/sidebar/architecture/api-guidelines.md index da301a11c..1a2fab77a 100644 --- a/docs/docs/sidebar/architecture/api-guidelines.md +++ b/docs/docs/sidebar/architecture/api-guidelines.md @@ -66,6 +66,7 @@ Sub-resources represent distinct capabilities of the node: | `/node/{hostname}/package/update` | Package | | `/node/{hostname}/package/updates` | Package | | `/node/{hostname}/log` | Log | +| `/node/{hostname}/log/source` | Log | | `/node/{hostname}/log/unit/{name}` | Log | 6. **Path Parameters Over Query Parameters** diff --git a/docs/docs/sidebar/features/features.md b/docs/docs/sidebar/features/features.md index 14e67fcee..f20b95ffb 100644 --- a/docs/docs/sidebar/features/features.md +++ b/docs/docs/sidebar/features/features.md @@ -32,6 +32,6 @@ OSAPI provides a comprehensive set of features for managing Linux systems. | 📡 | [Process Management](process-management.md) | List, inspect, and signal running processes | | 👤 | [User & Group Management](user-management.md) | Local user account and group management | | 📦 | [Package Management](package-management.md) | System package install, remove, update, and query | -| 📄 | [Log Management](log-management.md) | Query systemd journal entries by host or unit | +| 📄 | [Log Management](log-management.md) | Query systemd journal entries by host, unit, or source | diff --git a/docs/docs/sidebar/features/log-management.md b/docs/docs/sidebar/features/log-management.md index 1c8445245..39677aa9b 100644 --- a/docs/docs/sidebar/features/log-management.md +++ b/docs/docs/sidebar/features/log-management.md @@ -26,12 +26,19 @@ Returns journal log entries scoped to a specific systemd unit. The agent adds the `-u ` flag to the journalctl invocation alongside the same optional filters. +### Sources + +Returns a sorted list of unique syslog identifiers (log sources) available in +the journal. The agent runs `journalctl --field=SYSLOG_IDENTIFIER`. Use this to +discover what unit names are available before querying. + ## Operations | Operation | Description | | --------- | ---------------------------------------------- | | Query | Query journal entries for the host | | QueryUnit | Query journal entries for a specific unit name | +| Sources | List available log sources (syslog IDs) | ## CLI Usage @@ -46,6 +53,9 @@ osapi client node log query --target web-01 \ # Query journal entries for the sshd unit osapi client node log unit --target web-01 --name sshd.service +# List available log sources +osapi client node log source --target web-01 + # Broadcast log query to all hosts osapi client node log query --target _all --lines 20 ``` @@ -91,9 +101,9 @@ environments. ## Permissions -| Operation | Permission | -| ---------------- | ---------- | -| Query, QueryUnit | `log:read` | +| Operation | Permission | +| ------------------------- | ---------- | +| Query, QueryUnit, Sources | `log:read` | Log querying requires `log:read`, included in all built-in roles (`admin`, `write`, `read`). diff --git a/docs/docs/sidebar/sdk/client/operations/log.md b/docs/docs/sidebar/sdk/client/operations/log.md index 9d203e221..9df4c8cb6 100644 --- a/docs/docs/sidebar/sdk/client/operations/log.md +++ b/docs/docs/sidebar/sdk/client/operations/log.md @@ -5,7 +5,7 @@ sidebar_position: 4 # Log The `Log` service provides methods for querying the systemd journal on target -hosts. Access via `client.Log.Query()` and `client.Log.QueryUnit()`. +hosts. Access via `client.Log`. ## Methods @@ -13,6 +13,7 @@ hosts. Access via `client.Log.Query()` and `client.Log.QueryUnit()`. | -------------------------------------- | ----------------------------------------- | | `Query(ctx, hostname, opts)` | Query journal entries for the host | | `QueryUnit(ctx, hostname, unit, opts)` | Query journal entries for a specific unit | +| `Sources(ctx, hostname)` | List available log sources (syslog IDs) | ## Request Types @@ -57,6 +58,14 @@ for _, r := range resp.Data.Results { } } +// List available log sources on the host +srcResp, err := c.Log.Sources(ctx, "web-01") +for _, r := range srcResp.Data.Results { + for _, src := range r.Sources { + fmt.Println(src) + } +} + // Broadcast log query to all hosts resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{}) ``` @@ -83,15 +92,24 @@ resp, err := c.Log.Query(ctx, "_all", client.LogQueryOpts{}) | `PID` | `int` | Process ID that generated the entry | | `Hostname` | `string` | Hostname from the journal entry | +`LogSourceResult` is returned per host for the `Sources` method: + +| Field | Type | Description | +| ---------- | ---------- | -------------------------------- | +| `Hostname` | `string` | Target host | +| `Status` | `string` | `ok`, `skipped`, or `failed` | +| `Sources` | `[]string` | Syslog identifiers (sorted) | +| `Error` | `string` | Error message if the call failed | + ## Example - [`examples/sdk/client/log.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/log.go) ## Permissions -| Operation | Permission | -| ---------------- | ---------- | -| Query, QueryUnit | `log:read` | +| Operation | Permission | +| ------------------------- | ---------- | +| Query, QueryUnit, Sources | `log:read` | Log management is supported on the Debian OS family (Ubuntu, Debian, Raspbian). On unsupported platforms (Darwin, generic Linux) and inside containers, diff --git a/docs/docs/sidebar/usage/cli/client/node/log/source.md b/docs/docs/sidebar/usage/cli/client/node/log/source.md new file mode 100644 index 000000000..1071a93b4 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/node/log/source.md @@ -0,0 +1,53 @@ +# Source + +List available log sources (syslog identifiers) on a target host: + +```bash +$ osapi client node log source --target web-01 + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + SOURCE + cron + kernel + nginx + sshd + systemd +``` + +When targeting all hosts: + +```bash +$ osapi client node log source --target _all + + Job ID: 550e8400-e29b-41d4-a716-446655440000 + + web-01 + SOURCE + cron + kernel + nginx + sshd + + web-02 + SOURCE + kernel + systemd +``` + +## JSON Output + +Use `--json` to get the full API response: + +```bash +$ osapi client node log source --target web-01 --json +{"results":[{"hostname":"web-01","status":"ok","sources":["cron", +"kernel","nginx","sshd","systemd"]}],"job_id":"..."} +``` + +## Flags + +| Flag | Description | Default | +| -------------- | -------------------------------------------------------- | ------- | +| `-T, --target` | Target: `_any`, `_all`, hostname, or label (`group:web`) | `_any` | +| `-j, --json` | Output raw JSON response | | diff --git a/examples/sdk/client/log.go b/examples/sdk/client/log.go index e23e73819..b6a10fea5 100644 --- a/examples/sdk/client/log.go +++ b/examples/sdk/client/log.go @@ -78,6 +78,27 @@ func logExample() { } } + // List available log sources on the host. + fmt.Println("\n=== Listing log sources ===") + srcResp, err := c.Log.Sources(ctx, hostname) + if err != nil { + log.Fatalf("log sources failed: %v", err) + } + + for _, r := range srcResp.Data.Results { + if r.Error != "" { + fmt.Printf(" %s: ERROR %s\n", r.Hostname, r.Error) + continue + } + fmt.Printf(" %s: %d sources\n", r.Hostname, len(r.Sources)) + for _, src := range r.Sources[:min(10, len(r.Sources))] { + fmt.Printf(" %s\n", src) + } + if len(r.Sources) > 10 { + fmt.Printf(" ... and %d more\n", len(r.Sources)-10) + } + } + // Query entries for the sshd systemd unit. fmt.Println("\n=== Querying sshd.service entries ===") unitResp, err := c.Log.QueryUnit(ctx, hostname, "sshd.service",