diff --git a/CLAUDE.md b/CLAUDE.md index 97df6787..7f728794 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ go test -run TestName -v ./internal/job/... # Run a single test - **`internal/agent/`** - Node agent: consumer/handler/processor pipeline for job execution - **`internal/provider/`** - Operation implementations: `node/{host,disk,mem,load}`, `network/{dns,ping}` - **`internal/config/`** - Viper-based config from `osapi.yaml` -- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`client/` client library, `orchestrator/` DAG runner) +- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`client/` client library, `orchestrator/` DAG runner). See @docs/docs/sidebar/sdk/guidelines.md for SDK development rules - Shared `nats-client` and `nats-server` are sibling repos linked via `replace` in `go.mod` - **`github/`** - Temporary GitHub org config tooling (`repos.json` for declarative repo settings, `sync.sh` for drift detection via `gh` CLI). Untracked and intended to move to its own repo. @@ -173,7 +173,10 @@ Create `internal/api/{domain}/`: The SDK client library lives in `pkg/sdk/client/`. Its generated HTTP client uses the same combined OpenAPI spec as the server -(`internal/api/gen/api.yaml`). +(`internal/api/gen/api.yaml`). Follow the rules in +@docs/docs/sidebar/sdk/guidelines.md β€” especially: never expose `gen` +types in public method signatures, add JSON tags to all result types, +and wrap errors with context. **When modifying existing API specs:** diff --git a/README.md b/README.md index 2f2c55a6..4aa2d0bb 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,11 @@ them to be used as appliances. | --- | --- | | [nats-client][] | A Go package for connecting to and interacting with a NATS server | | [nats-server][] | A Go package for running an embedded NATS server | +| [osapi-orchestrator][] | Declarative infrastructure orchestration DSL built on the OSAPI SDK | [nats-client]: https://github.com/osapi-io/nats-client [nats-server]: https://github.com/osapi-io/nats-server +[osapi-orchestrator]: https://github.com/osapi-io/osapi-orchestrator ## πŸ“„ License diff --git a/cmd/client_container_docker_create.go b/cmd/client_container_docker_create.go index 3fc93770..57a864b2 100644 --- a/cmd/client_container_docker_create.go +++ b/cmd/client_container_docker_create.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerCreateCmd represents the clientContainerDockerCreate command. @@ -44,25 +44,16 @@ var clientContainerDockerCreateCmd = &cobra.Command{ volumeFlags, _ := cmd.Flags().GetStringSlice("volume") autoStart, _ := cmd.Flags().GetBool("auto-start") - body := gen.DockerCreateRequest{ + opts := client.DockerCreateOpts{ Image: image, + Name: name, AutoStart: &autoStart, + Env: envFlags, + Ports: portFlags, + Volumes: volumeFlags, } - if name != "" { - body.Name = &name - } - if len(envFlags) > 0 { - body.Env = &envFlags - } - if len(portFlags) > 0 { - body.Ports = &portFlags - } - if len(volumeFlags) > 0 { - body.Volumes = &volumeFlags - } - - resp, err := sdkClient.Docker.Create(ctx, host, body) + resp, err := sdkClient.Docker.Create(ctx, host, opts) if err != nil { cli.HandleError(err, logger) return @@ -76,7 +67,6 @@ var clientContainerDockerCreateCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_exec.go b/cmd/client_container_docker_exec.go index e92cce0f..b85f84a6 100644 --- a/cmd/client_container_docker_exec.go +++ b/cmd/client_container_docker_exec.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerExecCmd represents the clientContainerDockerExec command. @@ -43,18 +43,13 @@ var clientContainerDockerExecCmd = &cobra.Command{ envFlags, _ := cmd.Flags().GetStringSlice("env") workingDir, _ := cmd.Flags().GetString("working-dir") - body := gen.DockerExecRequest{ - Command: command, + opts := client.DockerExecOpts{ + Command: command, + Env: envFlags, + WorkingDir: workingDir, } - if len(envFlags) > 0 { - body.Env = &envFlags - } - if workingDir != "" { - body.WorkingDir = &workingDir - } - - resp, err := sdkClient.Docker.Exec(ctx, host, id, body) + resp, err := sdkClient.Docker.Exec(ctx, host, id, opts) if err != nil { cli.HandleError(err, logger) return @@ -68,7 +63,6 @@ var clientContainerDockerExecCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_inspect.go b/cmd/client_container_docker_inspect.go index ffcc2f92..3963b4c7 100644 --- a/cmd/client_container_docker_inspect.go +++ b/cmd/client_container_docker_inspect.go @@ -53,7 +53,6 @@ var clientContainerDockerInspectCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_list.go b/cmd/client_container_docker_list.go index f04346a4..eddd6a81 100644 --- a/cmd/client_container_docker_list.go +++ b/cmd/client_container_docker_list.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerListCmd represents the clientContainerDockerList command. @@ -40,14 +40,9 @@ var clientContainerDockerListCmd = &cobra.Command{ stateFlag, _ := cmd.Flags().GetString("state") limit, _ := cmd.Flags().GetInt("limit") - params := &gen.GetNodeContainerDockerParams{} - - if stateFlag != "" { - state := gen.GetNodeContainerDockerParamsState(stateFlag) - params.State = &state - } - if limit > 0 { - params.Limit = &limit + params := &client.DockerListParams{ + State: stateFlag, + Limit: limit, } resp, err := sdkClient.Docker.List(ctx, host, params) @@ -64,7 +59,6 @@ var clientContainerDockerListCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_pull.go b/cmd/client_container_docker_pull.go index e610078c..6d3e1e5e 100644 --- a/cmd/client_container_docker_pull.go +++ b/cmd/client_container_docker_pull.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerPullCmd represents the clientContainerDockerPull command. @@ -39,11 +39,11 @@ var clientContainerDockerPullCmd = &cobra.Command{ host, _ := cmd.Flags().GetString("target") image, _ := cmd.Flags().GetString("image") - body := gen.DockerPullRequest{ + opts := client.DockerPullOpts{ Image: image, } - resp, err := sdkClient.Docker.Pull(ctx, host, body) + resp, err := sdkClient.Docker.Pull(ctx, host, opts) if err != nil { cli.HandleError(err, logger) return @@ -57,7 +57,6 @@ var clientContainerDockerPullCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_remove.go b/cmd/client_container_docker_remove.go index dcbfb97f..49060943 100644 --- a/cmd/client_container_docker_remove.go +++ b/cmd/client_container_docker_remove.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerRemoveCmd represents the clientContainerDockerRemove command. @@ -40,9 +40,9 @@ var clientContainerDockerRemoveCmd = &cobra.Command{ id, _ := cmd.Flags().GetString("id") force, _ := cmd.Flags().GetBool("force") - params := &gen.DeleteNodeContainerDockerByIDParams{} + var params *client.DockerRemoveParams if force { - params.Force = &force + params = &client.DockerRemoveParams{Force: true} } resp, err := sdkClient.Docker.Remove(ctx, host, id, params) @@ -59,7 +59,6 @@ var clientContainerDockerRemoveCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_start.go b/cmd/client_container_docker_start.go index 4037cead..888c677f 100644 --- a/cmd/client_container_docker_start.go +++ b/cmd/client_container_docker_start.go @@ -52,7 +52,6 @@ var clientContainerDockerStartCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/cmd/client_container_docker_stop.go b/cmd/client_container_docker_stop.go index 7cebcbbd..9a1e248d 100644 --- a/cmd/client_container_docker_stop.go +++ b/cmd/client_container_docker_stop.go @@ -26,7 +26,7 @@ import ( "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" - "github.com/retr0h/osapi/pkg/sdk/client/gen" + "github.com/retr0h/osapi/pkg/sdk/client" ) // clientContainerDockerStopCmd represents the clientContainerDockerStop command. @@ -40,12 +40,12 @@ var clientContainerDockerStopCmd = &cobra.Command{ id, _ := cmd.Flags().GetString("id") timeout, _ := cmd.Flags().GetInt("timeout") - body := gen.DockerStopRequest{} + opts := client.DockerStopOpts{} if cmd.Flags().Changed("timeout") { - body.Timeout = &timeout + opts.Timeout = timeout } - resp, err := sdkClient.Docker.Stop(ctx, host, id, body) + resp, err := sdkClient.Docker.Stop(ctx, host, id, opts) if err != nil { cli.HandleError(err, logger) return @@ -59,7 +59,6 @@ var clientContainerDockerStopCmd = &cobra.Command{ if resp.Data.JobID != "" { fmt.Println() cli.PrintKV("Job ID", resp.Data.JobID) - fmt.Println() } for _, r := range resp.Data.Results { diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md index db45cb9c..b626db79 100644 --- a/docs/docs/sidebar/sdk/client/client.md +++ b/docs/docs/sidebar/sdk/client/client.md @@ -23,6 +23,7 @@ resp, err := client.Node.Hostname(ctx, "_any") | --------------------- | ---------------------------------- | | [Agent](agent.md) | Agent discovery and details | | [Audit](audit.md) | Audit log operations | +| [Docker](docker.md) | Container runtime operations | | [File](file.md) | File management (Object Store) | | [Health](health.md) | Health check operations | | [Job](job.md) | Async job queue operations | diff --git a/docs/docs/sidebar/sdk/client/docker.md b/docs/docs/sidebar/sdk/client/docker.md new file mode 100644 index 00000000..bbc92480 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/docker.md @@ -0,0 +1,90 @@ +--- +sidebar_position: 3 +--- + +# DockerService + +Docker container lifecycle management β€” create, list, inspect, start, stop, +remove, exec, and pull operations. + +## Methods + +| Method | Description | +| ------------------------------- | -------------------------------- | +| `Create(ctx, hostname, opts)` | Create a new container | +| `List(ctx, hostname, params)` | List containers | +| `Inspect(ctx, hostname, id)` | Get detailed container info | +| `Start(ctx, hostname, id)` | Start a stopped container | +| `Stop(ctx, hostname, id, opts)` | Stop a running container | +| `Remove(ctx, hostname, id, p)` | Remove a container | +| `Exec(ctx, hostname, id, opts)` | Execute a command in a container | +| `Pull(ctx, hostname, opts)` | Pull a container image | + +## Request Types + +The Docker service uses SDK-defined request types. Consumers never need to +import `gen`. + +| Type | Fields | +| -------------------- | ---------------------------------------------------- | +| `DockerCreateOpts` | Image, Name, Command, Env, Ports, Volumes, AutoStart | +| `DockerStopOpts` | Timeout | +| `DockerListParams` | State, Limit | +| `DockerRemoveParams` | Force | +| `DockerPullOpts` | Image | +| `DockerExecOpts` | Command | + +## Usage + +```go +import "github.com/retr0h/osapi/pkg/sdk/client" + +c := client.New("http://localhost:8080", token) + +// Pull an image +resp, err := c.Docker.Pull(ctx, "_any", client.DockerPullOpts{ + Image: "nginx:latest", +}) + +// Create a container +autoStart := true +resp, err := c.Docker.Create(ctx, "_any", client.DockerCreateOpts{ + Image: "nginx:latest", + Name: "web", + Ports: []string{"8080:80"}, + AutoStart: &autoStart, +}) + +// List running containers +resp, err := c.Docker.List(ctx, "_any", &client.DockerListParams{ + State: "running", +}) + +// Execute a command +resp, err := c.Docker.Exec(ctx, "_any", "web", client.DockerExecOpts{ + Command: []string{"hostname"}, +}) + +// Stop with timeout +resp, err := c.Docker.Stop(ctx, "_any", "web", client.DockerStopOpts{ + Timeout: 30, +}) + +// Force remove +resp, err := c.Docker.Remove(ctx, "_any", "web", &client.DockerRemoveParams{ + Force: true, +}) +``` + +## Permissions + +| Operation | Permission | +| --------- | ---------------- | +| Create | `docker:write` | +| List | `docker:read` | +| Inspect | `docker:read` | +| Start | `docker:write` | +| Stop | `docker:write` | +| Remove | `docker:write` | +| Exec | `docker:execute` | +| Pull | `docker:write` | diff --git a/docs/docs/sidebar/sdk/guidelines.md b/docs/docs/sidebar/sdk/guidelines.md new file mode 100644 index 00000000..b7529079 --- /dev/null +++ b/docs/docs/sidebar/sdk/guidelines.md @@ -0,0 +1,243 @@ +--- +sidebar_position: 1 +--- + +# SDK Development Guidelines + +Rules for developing the OSAPI Go SDK (`pkg/sdk/`). These apply to the client +library, orchestrator engine, and any new SDK packages. + +## Package Structure + +``` +pkg/sdk/ + client/ # HTTP client wrapping generated OpenAPI code + gen/ # Generated code (DO NOT edit manually) + osapi.go # Client constructor, service wiring + response.go # Response[T], Collection[T], error helpers + errors.go # Typed error hierarchy + node.go # NodeService methods + node_types.go # SDK result types + genβ†’SDK conversions + ... # One file per domain service + orchestrator/ # DAG-based task runner + plan.go # Plan, TaskFunc, TaskFuncWithResults + task.go # Task with deps, guards, error strategies + runner.go # DAG levelization and execution + result.go # Result, HostResult, TaskResult, Report + bridge.go # CollectionResult, StructToMap helpers + options.go # Hooks, error strategies, plan options + platform/ # Platform detection utilities +``` + +## Never Expose Generated Types + +The `gen/` package contains auto-generated OpenAPI client code. **No generated +type should appear in any public SDK method signature.** The SDK exists +specifically to hide `gen/` behind clean, stable types. + +For every `gen.*` request or response type used internally, define an SDK-level +equivalent: + +```go +// BAD β€” leaks gen type into public API +func (s *DockerService) Create( + ctx context.Context, + hostname string, + body gen.DockerCreateRequest, // consumer must import gen +) (*Response[Collection[DockerResult]], error) + +// GOOD β€” SDK-defined type wraps gen internally +func (s *DockerService) Create( + ctx context.Context, + hostname string, + opts DockerCreateOpts, // SDK type, no gen import needed +) (*Response[Collection[DockerResult]], error) +``` + +Inside the method, build the `gen.*` request from the SDK type. Map zero values +to nil pointers where the gen type uses `*string`, `*bool`, etc. + +## Result Types + +### JSON Tags Required + +Every exported struct field on every result/model type **must** have a +`json:"..."` tag with a snake_case key: + +```go +// GOOD +type HostnameResult struct { + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Labels map[string]string `json:"labels,omitempty"` +} +``` + +Tags are required because: + +- `StructToMap` (the bridge helper) uses JSON round-tripping to convert structs + to `map[string]any`. Without tags, Go uses PascalCase field names which don't + match the API's snake_case keys. +- Consumers may serialize SDK types to JSON for logging, storage, or forwarding. + Consistent keys matter. + +### omitempty Rules + +- **Use `omitempty`** on: pointer fields, optional slices/maps, error strings, + optional string fields +- **Do not use `omitempty`** on: `Changed bool` (must always be present), + required fields like `Hostname` + +### Collection Pattern + +Multi-target operations return `Collection[T]`: + +```go +type Collection[T any] struct { + Results []T `json:"results"` + JobID string `json:"job_id"` +} +``` + +Use `Collection.First()` for safe access to single-result responses instead of +indexing `Results[0]` directly. + +### Changed Field + +Every mutation result type must include `Changed bool`. The provider sets it, +the agent extracts it via `extractChanged()`, the API passes it through, and the +SDK exposes it. The full chain must be consistent. + +## Response Pattern + +All service methods return `*Response[T]`: + +```go +type Response[T any] struct { + Data T + rawJSON []byte +} +``` + +- `Data` β€” the typed SDK result +- `RawJSON()` β€” the raw HTTP response body for CLI `--json` mode and + orchestrator `Result.Data` population + +## Error Handling + +### checkError + +All service methods use `checkError()` to convert HTTP status codes into typed +errors: + +```go +if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON500, +); err != nil { + return nil, err +} +``` + +### Error Wrapping + +Wrap errors with context at the SDK boundary: + +```go +// GOOD +return nil, fmt.Errorf("docker create: %w", err) +return nil, fmt.Errorf("invalid audit ID: %w", err) + +// BAD β€” no context +return nil, err +``` + +### Nil Response Guard + +After `checkError`, always guard against nil response bodies: + +```go +if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} +} +``` + +## Orchestrator Bridge Helpers + +The orchestrator package provides two bridge helpers for converting SDK client +responses into orchestrator `Result` values. These exist so consumers like +`osapi-orchestrator` don't need to reimplement them. + +### CollectionResult + +Converts a `Collection[T]` response into an orchestrator `Result` with per-host +details: + +```go +return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, +), nil +``` + +- First arg: the `Collection[T]` from `resp.Data` +- Second arg: `resp.RawJSON()` to populate `Result.Data` (or `nil` to skip) +- Third arg: mapper function converting each result to `HostResult` + +### StructToMap + +Converts any struct with JSON tags to `map[string]any`. Use for non-collection +responses: + +```go +return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), +}, nil +``` + +## Adding a New Service + +When adding a new domain service to the SDK client: + +1. **Create `{domain}.go`** β€” service struct + methods, each calling gen client + and converting to SDK types +2. **Create `{domain}_types.go`** β€” SDK result types with JSON tags, SDK request + types (wrapping gen types), and genβ†’SDK conversion functions +3. **Create `{domain}_public_test.go`** β€” tests using `httptest.Server` mocks, + 100% coverage +4. **Wire in `osapi.go`** β€” add service field to `Client`, initialize in `New()` +5. **Never import `gen` in examples or consumer code** β€” if a consumer needs to + import `gen`, the SDK wrapper is incomplete + +## Testing + +- Use `httptest.Server` to mock API responses +- Test all HTTP status code paths (200, 400, 401, 403, 404, 500) +- Test nil response body path +- Test transport errors (unreachable server) +- Test all optional field branches in request type mapping +- Target 100% coverage on all SDK packages (excluding `gen/`) + +## Consumer Guidance + +SDK consumers (like `osapi-orchestrator`) should: + +- Use SDK `client.*` types directly β€” do not redefine them locally +- Use `CollectionResult` and `StructToMap` from the orchestrator bridge +- Use `Collection.First()` instead of `Results[0]` +- Never import `gen` β€” if you need to, the SDK is missing a wrapper +- Never panic on SDK responses β€” always propagate errors diff --git a/docs/docs/sidebar/sdk/sdk.md b/docs/docs/sidebar/sdk/sdk.md index 57a6a994..c7724135 100644 --- a/docs/docs/sidebar/sdk/sdk.md +++ b/docs/docs/sidebar/sdk/sdk.md @@ -8,9 +8,10 @@ OSAPI provides a Go SDK for programmatic access to the REST API. The SDK includes a typed client library and a DAG-based orchestrator for composing multi-step operations. -| Package | Description | -| -------------------------------------------- | -------------------------------- | -| [Client Library](client/client.md) | Typed Go client for the REST API | -| [Orchestrator](orchestrator/orchestrator.md) | DAG-based task orchestration | +| Page | Description | +| -------------------------------------------- | ---------------------------------- | +| [Guidelines](guidelines.md) | SDK development rules and patterns | +| [Client Library](client/client.md) | Typed Go client for the REST API | +| [Orchestrator](orchestrator/orchestrator.md) | DAG-based task orchestration | diff --git a/docs/plans/2026-03-13-sdk-quality-fixes-design.md b/docs/plans/2026-03-13-sdk-quality-fixes-design.md new file mode 100644 index 00000000..7d63a16f --- /dev/null +++ b/docs/plans/2026-03-13-sdk-quality-fixes-design.md @@ -0,0 +1,137 @@ +# SDK Quality Fixes Design + +## Problem + +A code review identified 9 issues across the OSAPI SDK and its primary consumer +(`osapi-orchestrator`). The central gap is that the SDK's bridge helpers are +incomplete, forcing `osapi-orchestrator` to reimplement result conversion and +duplicate ~200 lines of type definitions. + +## Fixes β€” osapi SDK (this repo) + +### #1: CollectionResult populates Result.Data + +`CollectionResult` currently only populates `HostResult.Data` per-host. It +leaves `Result.Data` nil, so `osapi-orchestrator` must call +`mustRawToMap(resp.RawJSON())` separately for every operation. + +**Fix:** Add a `rawJSON []byte` parameter. When non-nil, unmarshal it into +`Result.Data`. Callers pass `resp.RawJSON()` or `nil`. + +```go +func CollectionResult[T any]( + col Collection[T], + rawJSON []byte, + toHostResult func(T) HostResult, +) *Result +``` + +Update all callers in examples and tests. + +### #2: Docker SDK request types + +`DockerService.Create`, `List`, `Stop`, and `Remove` expose `gen.*` request +types. Every other service wraps gen types into SDK-defined types. + +**Fix:** Define in `docker_types.go`: + +- `DockerCreateOpts` β€” Image, Name, Command, Env, Ports, Volumes, AutoStart +- `DockerStopOpts` β€” Timeout +- `DockerListParams` β€” State +- `DockerRemoveParams` β€” Force + +Map to gen types inside the service methods. Consumers no longer import `gen`. + +### #5: Collection[T].First() + +Every consumer blindly indexes `Results[0]` with no bounds check. + +**Fix:** Add to `Collection[T]` in `response.go`: + +```go +func (c Collection[T]) First() (T, bool) { + if len(c.Results) == 0 { + var zero T + return zero, false + } + return c.Results[0], true +} +``` + +### #7: JSON tags on SDK result types + +SDK result types (`HostnameResult`, `DiskResult`, `CommandResult`, etc.) lack +`json:"..."` tags. `StructToMap` cannot produce correct keys without them, +forcing consumers to use `RawJSON()` as a workaround. + +**Fix:** Add `json` tags to all result types in `node_types.go`, +`docker_types.go`, `file_types.go`, `audit_types.go`, `health_types.go`, +`job_types.go`, `agent_types.go`. + +### #8: AuditService.Get UUID error wrapping + +`AuditService.Get` returns raw UUID parse error without context. +`JobService.Get` wraps it correctly. + +**Fix:** Wrap with `fmt.Errorf("invalid audit ID: %w", err)`. + +## Fixes β€” osapi-orchestrator (separate repo) + +### #4: mustRawToMap panic β†’ error return + +`mustRawToMap` panics on invalid JSON. A proxy 502 or truncated response would +crash the process. + +**Fix:** Change return to `(map[string]any, error)`. Propagate error through all +callers. + +After SDK fix #1 lands, `mustRawToMap` can be deleted entirely since +`CollectionResult` handles raw JSON internally. + +### #6: Delete duplicated types + +`result_types.go` (~200 lines) redefines SDK types: `HostnameResult`, +`DiskResult`, `MemoryResult`, `LoadResult`, `CommandResult`, `PingResult`, +`DNSConfigResult`, `DNSUpdateResult`, `FileDeployOpts`, `FileDeployResult`, +`FileStatusResult`, `FileUploadResult`, `FileChangedResult`, `AgentResult`, +`AgentListResult`, plus sub-types. + +**Fix:** Delete `result_types.go`. Use `client.*` types directly throughout +`ops.go` and any other files that reference these types. This eliminates the +duplicate definitions and the field-by-field copies in `ops.go`. + +### #9: HealthCheck target parameter + +`HealthCheck` accepts a `target` parameter but ignores it. Liveness checks hit +the API server directly β€” target routing doesn't apply. + +**Fix:** Remove the unused parameter. + +### #10: Report.Summary() duplication + +The orchestrator reimplements `Summary()` instead of delegating to the SDK's +version. + +**Fix:** Delegate to `sdk.Report.Summary()`. + +### Additional: Replace buildResult/toMap with SDK helpers + +Once SDK fixes #1 and #7 land: + +- Replace local `buildResult` with `orchestrator.CollectionResult` +- Replace local `toMap` with `orchestrator.StructToMap` +- Delete `mustRawToMap` (no longer needed) + +## What Does NOT Change + +- The orchestrator DAG engine (plan, task, runner) β€” already solid +- `Response[T]` and `Collection[T]` pattern β€” correct design +- Error hierarchy (`checkError`, `AuthError`, etc.) β€” clean +- `MetricsService` using `http.DefaultClient` β€” intentional, `/metrics` is a + Prometheus endpoint outside the auth middleware + +## Order of Operations + +1. Fix osapi SDK first (#1, #2, #5, #7, #8) β€” with tests, 100% coverage +2. Fix osapi-orchestrator (#4, #6, #9, #10, plus adopt SDK helpers) β€” depends on + SDK changes being published diff --git a/docs/plans/2026-03-13-sdk-quality-fixes.md b/docs/plans/2026-03-13-sdk-quality-fixes.md new file mode 100644 index 00000000..16e4e1b1 --- /dev/null +++ b/docs/plans/2026-03-13-sdk-quality-fixes.md @@ -0,0 +1,507 @@ +# SDK Quality Fixes Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development +> to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix 9 SDK quality issues identified by code review β€” add JSON tags to +result types, wrap Docker gen types, add Collection.First(), fix +CollectionResult to populate Result.Data, fix error wrapping, then update +osapi-orchestrator to use SDK types directly. + +**Architecture:** Fix the SDK client types first (JSON tags, Docker wrappers, +Collection.First), then fix the bridge helper (CollectionResult), then update +osapi-orchestrator to consume the improvements (delete duplicated types, use SDK +helpers). + +**Tech Stack:** Go 1.25, generics, testify/suite + +--- + +## Chunk 1: osapi SDK fixes + +### Task 1: Add JSON tags to all SDK client result types + +**Files:** + +- Modify: `pkg/sdk/client/node_types.go` +- Modify: `pkg/sdk/client/docker_types.go` +- Modify: `pkg/sdk/client/file_types.go` +- Modify: `pkg/sdk/client/audit_types.go` +- Modify: `pkg/sdk/client/health_types.go` +- Modify: `pkg/sdk/client/job_types.go` +- Modify: `pkg/sdk/client/agent_types.go` + +- [ ] **Step 1: Add JSON tags to node_types.go** + +Add `json:"..."` tags to all exported struct fields in these types: +`Collection`, `Disk`, `HostnameResult`, `NodeStatus`, `DiskResult`, +`MemoryResult`, `LoadResult`, `OSInfoResult`, `UptimeResult`, `DNSConfig`, +`DNSUpdateResult`, `PingResult`, `CommandResult`, `LoadAverage`, `Memory`, +`OSInfo`. + +Use snake_case keys matching the API response format. For example: + +```go +type Collection[T any] struct { + Results []T `json:"results"` + JobID string `json:"job_id"` +} + +type HostnameResult struct { + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Labels map[string]string `json:"labels,omitempty"` +} +``` + +Apply to all types β€” every exported field gets a `json` tag. + +- [ ] **Step 2: Add JSON tags to docker_types.go** + +Same pattern for: `DockerResult`, `DockerListResult`, `DockerSummaryItem`, +`DockerDetailResult`, `DockerActionResult`, `DockerExecResult`, +`DockerPullResult`. + +- [ ] **Step 3: Add JSON tags to remaining types files** + +Add tags to all result/model types in `file_types.go`, `audit_types.go`, +`health_types.go`, `job_types.go`, `agent_types.go`. + +- [ ] **Step 4: Run tests** + +Run: `go test ./pkg/sdk/client/... -count=1` Expected: PASS β€” JSON tags don't +break existing behavior. + +- [ ] **Step 5: Commit** + +``` +feat(sdk): add JSON tags to all client result types +``` + +--- + +### Task 2: Add Collection[T].First() method + +**Files:** + +- Modify: `pkg/sdk/client/node_types.go` +- Test: `pkg/sdk/client/node_types_test.go` (or appropriate test file) + +- [ ] **Step 1: Write the failing test** + +Add to the existing client test suite: + +```go +func (s *SuiteType) TestCollectionFirst() { + tests := []struct { + name string + col client.Collection[client.HostnameResult] + validateFunc func(client.HostnameResult, bool) + }{ + { + name: "returns first result and true", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01"}, + {Hostname: "web-02"}, + }, + JobID: "job-1", + }, + validateFunc: func(r client.HostnameResult, ok bool) { + s.True(ok) + s.Equal("web-01", r.Hostname) + }, + }, + { + name: "returns zero value and false when empty", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{}, + }, + validateFunc: func(r client.HostnameResult, ok bool) { + s.False(ok) + s.Equal("", r.Hostname) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + r, ok := tt.col.First() + tt.validateFunc(r, ok) + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./pkg/sdk/client/... -run TestCollectionFirst -v` Expected: FAIL β€” +`First` not defined + +- [ ] **Step 3: Implement First()** + +Add to `node_types.go` after `Collection` definition: + +```go +// First returns the first result and true, or the zero value +// and false if the collection is empty. +func (c Collection[T]) First() (T, bool) { + if len(c.Results) == 0 { + var zero T + return zero, false + } + + return c.Results[0], true +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./pkg/sdk/client/... -run TestCollectionFirst -v` Expected: PASS + +- [ ] **Step 5: Commit** + +``` +feat(sdk): add Collection[T].First() method +``` + +--- + +### Task 3: Wrap Docker gen types with SDK-defined request types + +**Files:** + +- Modify: `pkg/sdk/client/docker_types.go` +- Modify: `pkg/sdk/client/docker.go` +- Modify: `pkg/sdk/client/docker_public_test.go` + +- [ ] **Step 1: Define SDK Docker request types in docker_types.go** + +Add after the result types: + +```go +// DockerCreateOpts contains options for creating a container. +type DockerCreateOpts struct { + // Image is the container image reference (required). + Image string + // Name is an optional container name. + Name string + // Command overrides the image's default command. + Command []string + // Env is environment variables in KEY=VALUE format. + Env []string + // Ports is port mappings in host_port:container_port format. + Ports []string + // Volumes is volume mounts in host_path:container_path format. + Volumes []string + // AutoStart starts the container after creation (default true). + AutoStart *bool +} + +// DockerStopOpts contains options for stopping a container. +type DockerStopOpts struct { + // Timeout is seconds to wait before killing. Zero uses default. + Timeout int +} + +// DockerListParams contains parameters for listing containers. +type DockerListParams struct { + // State filters by state: "running", "stopped", "all". + State string + // Limit caps the number of results. + Limit int +} + +// DockerRemoveParams contains parameters for removing a container. +type DockerRemoveParams struct { + // Force forces removal of a running container. + Force bool +} +``` + +- [ ] **Step 2: Update Docker service methods to use SDK types** + +Change method signatures in `docker.go`: + +`Create`: Change `body gen.DockerCreateRequest` to `opts DockerCreateOpts`. +Inside, build `gen.DockerCreateRequest` from the opts fields, converting zero +values to nil pointers. + +`Stop`: Change `body gen.DockerStopRequest` to `opts DockerStopOpts`. Build +`gen.DockerStopRequest` from opts. + +`List`: Change `params *gen.GetNodeContainerDockerParams` to +`params *DockerListParams`. Build gen params from SDK params. + +`Remove`: Change `params *gen.DeleteNodeContainerDockerByIDParams` to +`params *DockerRemoveParams`. Build gen params from SDK params. + +- [ ] **Step 3: Update tests** + +Update `docker_public_test.go` to use the new SDK types instead of gen types. +Also update any examples that reference the old signatures. + +- [ ] **Step 4: Update all callers** + +Search for `gen.DockerCreateRequest`, `gen.DockerStopRequest`, +`gen.GetNodeContainerDockerParams`, `gen.DeleteNodeContainerDockerByIDParams` +in: + +- `examples/sdk/client/container.go` +- `examples/sdk/orchestrator/features/container-targeting.go` +- `examples/sdk/orchestrator/operations/docker-*.go` + +Replace with the new SDK types. + +- [ ] **Step 5: Build and test** + +Run: `go build ./...` Run: `go test ./pkg/sdk/client/... -count=1` Run: Build +each docker example: +`go build examples/sdk/orchestrator/operations/docker-pull.go` etc. Expected: +All compile, all tests pass + +- [ ] **Step 6: Commit** + +``` +refactor(sdk): wrap Docker gen types with SDK-defined request types +``` + +--- + +### Task 4: Fix CollectionResult to populate Result.Data + +**Files:** + +- Modify: `pkg/sdk/orchestrator/bridge.go` +- Modify: `pkg/sdk/orchestrator/bridge_public_test.go` +- Modify: `pkg/sdk/orchestrator/bridge_test.go` + +- [ ] **Step 1: Update CollectionResult signature** + +Add `rawJSON []byte` parameter: + +```go +func CollectionResult[T any]( + col client.Collection[T], + rawJSON []byte, + toHostResult func(T) HostResult, +) *Result +``` + +When `rawJSON` is non-nil, unmarshal into `Result.Data`. Use `jsonUnmarshalFn` +(already injectable for testing). + +- [ ] **Step 2: Update tests** + +Update all test cases in `bridge_public_test.go` to pass `nil` for `rawJSON` +(existing behavior preserved). Add new test cases: + +- rawJSON populated: pass valid JSON, verify Result.Data is set +- rawJSON nil: verify Result.Data is nil (existing behavior) +- rawJSON invalid: verify Result.Data is nil (graceful degradation) + +- [ ] **Step 3: Update all callers** + +Update all example files that call `CollectionResult` to pass `resp.RawJSON()` +as the second argument. + +- [ ] **Step 4: Run tests** + +Run: `go test ./pkg/sdk/orchestrator/... -count=1` Run: Build all examples +Expected: PASS, all compile + +- [ ] **Step 5: Commit** + +``` +feat(orchestrator): populate Result.Data from raw JSON in CollectionResult +``` + +--- + +### Task 5: Fix AuditService.Get UUID error wrapping + +**Files:** + +- Modify: `pkg/sdk/client/audit.go:78-81` + +- [ ] **Step 1: Fix the error wrapping** + +Change: + +```go +parsedID, err := uuid.Parse(id) +if err != nil { + return nil, err +} +``` + +To: + +```go +parsedID, err := uuid.Parse(id) +if err != nil { + return nil, fmt.Errorf("invalid audit ID: %w", err) +} +``` + +- [ ] **Step 2: Update test if one exists** + +Check if there's a test for invalid audit ID. If so, update the expected error +message. + +- [ ] **Step 3: Run tests** + +Run: `go test ./pkg/sdk/client/... -count=1` Expected: PASS + +- [ ] **Step 4: Commit** + +``` +fix(sdk): wrap audit UUID parse error with context +``` + +--- + +### Task 6: Final SDK verification + +- [ ] **Step 1: Full test suite** + +Run: `go test ./... -count=1` Expected: All pass + +- [ ] **Step 2: Lint and format** + +```bash +find . -type f -name '*.go' -not -name '*.gen.go' -not -name '*.pb.go' \ + -not -path './.worktrees/*' -not -path './.claude/*' \ + | xargs go tool github.com/segmentio/golines \ + --base-formatter="go tool mvdan.cc/gofumpt" -w +go tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint run \ + --config .golangci.yml +``` + +Expected: 0 issues + +- [ ] **Step 3: Coverage** + +Run: +`go test ./pkg/sdk/... -coverprofile=/tmp/sdk.out && go tool cover -func=/tmp/sdk.out | grep -v gen | grep -v '100.0%'` +Expected: All SDK packages at 100% (excluding gen) + +- [ ] **Step 4: Build all examples** + +```bash +for f in examples/sdk/orchestrator/operations/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +for f in examples/sdk/orchestrator/features/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +``` + +Expected: Zero failures + +- [ ] **Step 5: Commit any fixes** + +``` +chore: SDK quality fixes verification +``` + +--- + +## Chunk 2: osapi-orchestrator fixes + +These changes are in the separate repo at `~/git/osapi-io/osapi-orchestrator/`. + +### Task 7: Update osapi-orchestrator to use SDK types directly + +**Files:** + +- Delete: `pkg/orchestrator/result_types.go` +- Modify: `pkg/orchestrator/ops.go` +- Modify: `pkg/orchestrator/result.go` +- Modify: any test files that reference deleted types + +**Prerequisites:** Tasks 1-6 must be complete and the updated osapi SDK must be +available (update `go.mod` to point at the new version or use a `replace` +directive). + +- [ ] **Step 1: Update go.mod to use latest SDK** + +Either `go get github.com/retr0h/osapi@latest` or add a `replace` directive +pointing to the local checkout. + +- [ ] **Step 2: Delete result_types.go** + +Remove the file entirely. All types it defines have equivalents in the SDK +`client` package. + +- [ ] **Step 3: Update ops.go imports and types** + +Replace all local type references with SDK `client.*` types: + +- `HostnameResult` β†’ `client.HostnameResult` +- `CommandResult` β†’ `client.CommandResult` +- `FileDeployOpts` β†’ `client.FileDeployOpts` +- etc. + +Replace `buildResult` calls with `orchestrator.CollectionResult`: + +```go +return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, +), nil +``` + +Delete `buildResult`, `toMap`, `mustRawToMap` helper functions. + +- [ ] **Step 4: Fix mustRawToMap callers that aren't Collection** + +For non-collection operations (FileDeploy, FileStatus, FileUpload, FileChanged, +AgentList, AgentGet), replace: + +```go +Data: mustRawToMap(resp.RawJSON()), +``` + +With: + +```go +Data: orchestrator.StructToMap(resp.Data), +``` + +This works now because SDK types have JSON tags (Task 1). + +- [ ] **Step 5: Update result.go** + +Delete duplicated `Summary()` β€” delegate to `sdk.Report.Summary()`. + +- [ ] **Step 6: Fix HealthCheck target parameter** + +Remove the unused `target string` parameter from `HealthCheck()`. Update all +callers. + +- [ ] **Step 7: Update tests** + +Fix all test files that reference deleted types or changed signatures. Run full +test suite. + +- [ ] **Step 8: Build and test** + +Run: `go test ./... -count=1` Run: `go build ./...` Expected: All pass, all +compile + +- [ ] **Step 9: Commit** + +``` +refactor: use SDK types directly, remove duplicated types + +Delete result_types.go (~200 lines of duplicated SDK types). +Replace buildResult/toMap/mustRawToMap with SDK bridge helpers. +Use client.* types directly throughout ops.go. +``` diff --git a/examples/sdk/client/container.go b/examples/sdk/client/container.go index dbaa2cb8..08e47173 100644 --- a/examples/sdk/client/container.go +++ b/examples/sdk/client/container.go @@ -31,7 +31,6 @@ import ( "os" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" ) func main() { @@ -50,7 +49,7 @@ func main() { target := "_any" // Pull an image. - pull, err := c.Docker.Pull(ctx, target, gen.DockerPullRequest{ + pull, err := c.Docker.Pull(ctx, target, client.DockerPullOpts{ Image: "nginx:alpine", }) if err != nil { @@ -63,11 +62,10 @@ func main() { } // Create a container. - name := "osapi-example" autoStart := true - create, err := c.Docker.Create(ctx, target, gen.DockerCreateRequest{ + create, err := c.Docker.Create(ctx, target, client.DockerCreateOpts{ Image: "nginx:alpine", - Name: &name, + Name: "osapi-example", AutoStart: &autoStart, }) if err != nil { @@ -82,9 +80,8 @@ func main() { } // List running containers. - state := gen.Running - list, err := c.Docker.List(ctx, target, &gen.GetNodeContainerDockerParams{ - State: &state, + list, err := c.Docker.List(ctx, target, &client.DockerListParams{ + State: "running", }) if err != nil { log.Fatalf("list: %v", err) @@ -109,7 +106,7 @@ func main() { } // Exec a command inside the container. - exec, err := c.Docker.Exec(ctx, target, containerID, gen.DockerExecRequest{ + exec, err := c.Docker.Exec(ctx, target, containerID, client.DockerExecOpts{ Command: []string{"cat", "/etc/hostname"}, }) if err != nil { @@ -122,9 +119,8 @@ func main() { } // Stop the container. - timeout := 5 - stop, err := c.Docker.Stop(ctx, target, containerID, gen.DockerStopRequest{ - Timeout: &timeout, + stop, err := c.Docker.Stop(ctx, target, containerID, client.DockerStopOpts{ + Timeout: 5, }) if err != nil { log.Fatalf("stop: %v", err) @@ -136,14 +132,11 @@ func main() { } // Remove the container. - force := true remove, err := c.Docker.Remove( ctx, target, containerID, - &gen.DeleteNodeContainerDockerByIDParams{ - Force: &force, - }, + &client.DockerRemoveParams{Force: true}, ) if err != nil { log.Fatalf("remove: %v", err) diff --git a/examples/sdk/orchestrator/features/basic.go b/examples/sdk/orchestrator/features/basic.go index 6990bada..019e6a53 100644 --- a/examples/sdk/orchestrator/features/basic.go +++ b/examples/sdk/orchestrator/features/basic.go @@ -90,7 +90,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/broadcast.go b/examples/sdk/orchestrator/features/broadcast.go index 4a363104..ba3bfaa2 100644 --- a/examples/sdk/orchestrator/features/broadcast.go +++ b/examples/sdk/orchestrator/features/broadcast.go @@ -80,7 +80,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/container-targeting.go b/examples/sdk/orchestrator/features/container-targeting.go index 91d97bf5..5135cc47 100644 --- a/examples/sdk/orchestrator/features/container-targeting.go +++ b/examples/sdk/orchestrator/features/container-targeting.go @@ -43,7 +43,6 @@ import ( "time" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) @@ -52,8 +51,6 @@ const ( containerImage = "ubuntu:24.04" ) -func ptr(s string) *string { return &s } - func main() { url := os.Getenv("OSAPI_URL") if url == "" { @@ -97,14 +94,13 @@ func main() { // ── Pre-cleanup: remove leftover container from previous run ─ // Swallow errors β€” the container may not exist. - force := true preCleanup := plan.TaskFunc("pre-cleanup", func( ctx context.Context, c *client.Client, ) (*orchestrator.Result, error) { _, _ = c.Docker.Remove(ctx, target, containerName, - &gen.DeleteNodeContainerDockerByIDParams{Force: &force}, + &client.DockerRemoveParams{Force: true}, ) return &orchestrator.Result{Changed: false}, nil @@ -118,14 +114,14 @@ func main() { ctx context.Context, c *client.Client, ) (*orchestrator.Result, error) { - resp, err := c.Docker.Pull(ctx, target, gen.DockerPullRequest{ + resp, err := c.Docker.Pull(ctx, target, client.DockerPullOpts{ Image: containerImage, }) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerPullResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -146,17 +142,17 @@ func main() { ctx context.Context, c *client.Client, ) (*orchestrator.Result, error) { - resp, err := c.Docker.Create(ctx, target, gen.DockerCreateRequest{ + resp, err := c.Docker.Create(ctx, target, client.DockerCreateOpts{ Image: containerImage, - Name: ptr(containerName), + Name: containerName, AutoStart: &autoStart, - Command: &[]string{"sleep", "600"}, + Command: []string{"sleep", "600"}, }) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -177,13 +173,13 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Docker.Exec(ctx, target, containerName, - gen.DockerExecRequest{Command: []string{"hostname"}}, + client.DockerExecOpts{Command: []string{"hostname"}}, ) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerExecResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -202,13 +198,13 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Docker.Exec(ctx, target, containerName, - gen.DockerExecRequest{Command: []string{"uname", "-a"}}, + client.DockerExecOpts{Command: []string{"uname", "-a"}}, ) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerExecResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -227,7 +223,7 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Docker.Exec(ctx, target, containerName, - gen.DockerExecRequest{ + client.DockerExecOpts{ Command: []string{"sh", "-c", "head -2 /etc/os-release"}, }, ) @@ -235,7 +231,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerExecResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -260,7 +256,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerDetailResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -294,13 +290,13 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Docker.Remove(ctx, target, containerName, - &gen.DeleteNodeContainerDockerByIDParams{Force: &force}, + &client.DockerRemoveParams{Force: true}, ) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerActionResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/error-strategy.go b/examples/sdk/orchestrator/features/error-strategy.go index 0988f5fa..87e36b72 100644 --- a/examples/sdk/orchestrator/features/error-strategy.go +++ b/examples/sdk/orchestrator/features/error-strategy.go @@ -92,7 +92,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/guards.go b/examples/sdk/orchestrator/features/guards.go index 08f7f70e..d3c6bc42 100644 --- a/examples/sdk/orchestrator/features/guards.go +++ b/examples/sdk/orchestrator/features/guards.go @@ -91,7 +91,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/hooks.go b/examples/sdk/orchestrator/features/hooks.go index 42d9576f..e840d1e4 100644 --- a/examples/sdk/orchestrator/features/hooks.go +++ b/examples/sdk/orchestrator/features/hooks.go @@ -133,7 +133,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -157,7 +157,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DiskResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/only-if-changed.go b/examples/sdk/orchestrator/features/only-if-changed.go index fecd42a3..543a1aa2 100644 --- a/examples/sdk/orchestrator/features/only-if-changed.go +++ b/examples/sdk/orchestrator/features/only-if-changed.go @@ -75,7 +75,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/parallel.go b/examples/sdk/orchestrator/features/parallel.go index bbca43aa..c63fdb10 100644 --- a/examples/sdk/orchestrator/features/parallel.go +++ b/examples/sdk/orchestrator/features/parallel.go @@ -105,7 +105,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -129,7 +129,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DiskResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, @@ -153,7 +153,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.MemoryResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/result-decode.go b/examples/sdk/orchestrator/features/result-decode.go index 5fd37d14..c6caf3a6 100644 --- a/examples/sdk/orchestrator/features/result-decode.go +++ b/examples/sdk/orchestrator/features/result-decode.go @@ -65,7 +65,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/task-func-results.go b/examples/sdk/orchestrator/features/task-func-results.go index 1cbc9098..41d28e87 100644 --- a/examples/sdk/orchestrator/features/task-func-results.go +++ b/examples/sdk/orchestrator/features/task-func-results.go @@ -88,7 +88,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/features/task-func.go b/examples/sdk/orchestrator/features/task-func.go index ed6c3ab5..1c1dee53 100644 --- a/examples/sdk/orchestrator/features/task-func.go +++ b/examples/sdk/orchestrator/features/task-func.go @@ -88,7 +88,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/command-exec.go b/examples/sdk/orchestrator/operations/command-exec.go index c8c99fb6..0954a799 100644 --- a/examples/sdk/orchestrator/operations/command-exec.go +++ b/examples/sdk/orchestrator/operations/command-exec.go @@ -71,7 +71,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.CommandResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/command-shell.go b/examples/sdk/orchestrator/operations/command-shell.go index 3bfb2855..c5cb3755 100644 --- a/examples/sdk/orchestrator/operations/command-shell.go +++ b/examples/sdk/orchestrator/operations/command-shell.go @@ -71,7 +71,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.CommandResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-create.go b/examples/sdk/orchestrator/operations/docker-create.go index 8782f82d..2f322cdd 100644 --- a/examples/sdk/orchestrator/operations/docker-create.go +++ b/examples/sdk/orchestrator/operations/docker-create.go @@ -32,7 +32,6 @@ import ( "os" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) @@ -64,14 +63,14 @@ func main() { ctx context.Context, cc *client.Client, ) (*orchestrator.Result, error) { - resp, err := cc.Docker.Create(ctx, "_any", gen.DockerCreateRequest{ + resp, err := cc.Docker.Create(ctx, "_any", client.DockerCreateOpts{ Image: "nginx:latest", }) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-exec.go b/examples/sdk/orchestrator/operations/docker-exec.go index b47b8420..2729be83 100644 --- a/examples/sdk/orchestrator/operations/docker-exec.go +++ b/examples/sdk/orchestrator/operations/docker-exec.go @@ -32,7 +32,6 @@ import ( "os" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) @@ -65,13 +64,13 @@ func main() { cc *client.Client, ) (*orchestrator.Result, error) { resp, err := cc.Docker.Exec(ctx, "_any", "container-name", - gen.DockerExecRequest{Command: []string{"hostname"}}, + client.DockerExecOpts{Command: []string{"hostname"}}, ) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerExecResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-inspect.go b/examples/sdk/orchestrator/operations/docker-inspect.go index d8099c4a..83f18737 100644 --- a/examples/sdk/orchestrator/operations/docker-inspect.go +++ b/examples/sdk/orchestrator/operations/docker-inspect.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerDetailResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-list.go b/examples/sdk/orchestrator/operations/docker-list.go index e93b4c94..cc31058e 100644 --- a/examples/sdk/orchestrator/operations/docker-list.go +++ b/examples/sdk/orchestrator/operations/docker-list.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerListResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-pull.go b/examples/sdk/orchestrator/operations/docker-pull.go index 9186d6d1..5ea0d41d 100644 --- a/examples/sdk/orchestrator/operations/docker-pull.go +++ b/examples/sdk/orchestrator/operations/docker-pull.go @@ -32,7 +32,6 @@ import ( "os" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) @@ -64,14 +63,14 @@ func main() { ctx context.Context, cc *client.Client, ) (*orchestrator.Result, error) { - resp, err := cc.Docker.Pull(ctx, "_any", gen.DockerPullRequest{ + resp, err := cc.Docker.Pull(ctx, "_any", client.DockerPullOpts{ Image: "alpine:latest", }) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerPullResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-remove.go b/examples/sdk/orchestrator/operations/docker-remove.go index 8adf85d1..51350ed3 100644 --- a/examples/sdk/orchestrator/operations/docker-remove.go +++ b/examples/sdk/orchestrator/operations/docker-remove.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerActionResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-start.go b/examples/sdk/orchestrator/operations/docker-start.go index ba9b083d..f40a6ab7 100644 --- a/examples/sdk/orchestrator/operations/docker-start.go +++ b/examples/sdk/orchestrator/operations/docker-start.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerActionResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/docker-stop.go b/examples/sdk/orchestrator/operations/docker-stop.go index 64a35b60..7b670766 100644 --- a/examples/sdk/orchestrator/operations/docker-stop.go +++ b/examples/sdk/orchestrator/operations/docker-stop.go @@ -32,7 +32,6 @@ import ( "os" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) @@ -65,13 +64,13 @@ func main() { cc *client.Client, ) (*orchestrator.Result, error) { resp, err := cc.Docker.Stop( - ctx, "_any", "container-name", gen.DockerStopRequest{}, + ctx, "_any", "container-name", client.DockerStopOpts{}, ) if err != nil { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DockerActionResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/network-dns-get.go b/examples/sdk/orchestrator/operations/network-dns-get.go index cb1fd560..b41a9b36 100644 --- a/examples/sdk/orchestrator/operations/network-dns-get.go +++ b/examples/sdk/orchestrator/operations/network-dns-get.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DNSConfig) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/network-dns-update.go b/examples/sdk/orchestrator/operations/network-dns-update.go index 32a4cf26..80489a1e 100644 --- a/examples/sdk/orchestrator/operations/network-dns-update.go +++ b/examples/sdk/orchestrator/operations/network-dns-update.go @@ -71,7 +71,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DNSUpdateResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/network-ping.go b/examples/sdk/orchestrator/operations/network-ping.go index 0ae09d34..dcc60d2a 100644 --- a/examples/sdk/orchestrator/operations/network-ping.go +++ b/examples/sdk/orchestrator/operations/network-ping.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.PingResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-disk.go b/examples/sdk/orchestrator/operations/node-disk.go index 3cf975c5..24431e1a 100644 --- a/examples/sdk/orchestrator/operations/node-disk.go +++ b/examples/sdk/orchestrator/operations/node-disk.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.DiskResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-hostname.go b/examples/sdk/orchestrator/operations/node-hostname.go index c6d701a0..e9880975 100644 --- a/examples/sdk/orchestrator/operations/node-hostname.go +++ b/examples/sdk/orchestrator/operations/node-hostname.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.HostnameResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-load.go b/examples/sdk/orchestrator/operations/node-load.go index f822b677..a59465d8 100644 --- a/examples/sdk/orchestrator/operations/node-load.go +++ b/examples/sdk/orchestrator/operations/node-load.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.LoadResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-memory.go b/examples/sdk/orchestrator/operations/node-memory.go index 25088df5..0957538a 100644 --- a/examples/sdk/orchestrator/operations/node-memory.go +++ b/examples/sdk/orchestrator/operations/node-memory.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.MemoryResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-status.go b/examples/sdk/orchestrator/operations/node-status.go index 3daf5819..db5b71d9 100644 --- a/examples/sdk/orchestrator/operations/node-status.go +++ b/examples/sdk/orchestrator/operations/node-status.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.NodeStatus) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/examples/sdk/orchestrator/operations/node-uptime.go b/examples/sdk/orchestrator/operations/node-uptime.go index fc18da8f..beba5273 100644 --- a/examples/sdk/orchestrator/operations/node-uptime.go +++ b/examples/sdk/orchestrator/operations/node-uptime.go @@ -68,7 +68,7 @@ func main() { return nil, err } - return orchestrator.CollectionResult(resp.Data, + return orchestrator.CollectionResult(resp.Data, resp.RawJSON(), func(r client.UptimeResult) orchestrator.HostResult { return orchestrator.HostResult{ Hostname: r.Hostname, diff --git a/pkg/sdk/client/agent_types.go b/pkg/sdk/client/agent_types.go index e0ee686c..b67b0349 100644 --- a/pkg/sdk/client/agent_types.go +++ b/pkg/sdk/client/agent_types.go @@ -28,81 +28,81 @@ import ( // Agent represents a registered OSAPI agent. type Agent struct { - Hostname string - Status string - State string - Labels map[string]string - Architecture string - CPUCount int - Fqdn string - KernelVersion string - PackageMgr string - ServiceMgr string - LoadAverage *LoadAverage - Memory *Memory - OSInfo *OSInfo - PrimaryInterface string - Interfaces []NetworkInterface - Routes []Route - Conditions []Condition - Timeline []TimelineEvent - Uptime string - StartedAt time.Time - RegisteredAt time.Time - Facts map[string]any + Hostname string `json:"hostname"` + Status string `json:"status"` + State string `json:"state,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Architecture string `json:"architecture,omitempty"` + CPUCount int `json:"cpu_count"` + Fqdn string `json:"fqdn,omitempty"` + KernelVersion string `json:"kernel_version,omitempty"` + PackageMgr string `json:"package_mgr,omitempty"` + ServiceMgr string `json:"service_mgr,omitempty"` + LoadAverage *LoadAverage `json:"load_average,omitempty"` + Memory *Memory `json:"memory,omitempty"` + OSInfo *OSInfo `json:"os_info,omitempty"` + PrimaryInterface string `json:"primary_interface,omitempty"` + Interfaces []NetworkInterface `json:"interfaces,omitempty"` + Routes []Route `json:"routes,omitempty"` + Conditions []Condition `json:"conditions,omitempty"` + Timeline []TimelineEvent `json:"timeline,omitempty"` + Uptime string `json:"uptime,omitempty"` + StartedAt time.Time `json:"started_at"` + RegisteredAt time.Time `json:"registered_at"` + Facts map[string]any `json:"facts,omitempty"` } // Condition represents a node condition evaluated agent-side. type Condition struct { - Type string - Status bool - Reason string - LastTransitionTime time.Time + Type string `json:"type"` + Status bool `json:"status"` + Reason string `json:"reason,omitempty"` + LastTransitionTime time.Time `json:"last_transition_time"` } // AgentList is a collection of agents. type AgentList struct { - Agents []Agent - Total int + Agents []Agent `json:"agents"` + Total int `json:"total"` } // NetworkInterface represents a network interface on an agent. type NetworkInterface struct { - Name string - Family string - IPv4 string - IPv6 string - MAC string + Name string `json:"name"` + Family string `json:"family,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + MAC string `json:"mac,omitempty"` } // Route represents a network routing table entry. type Route struct { - Destination string - Gateway string - Interface string - Mask string - Flags string - Metric int + Destination string `json:"destination"` + Gateway string `json:"gateway"` + Interface string `json:"interface"` + Mask string `json:"mask,omitempty"` + Flags string `json:"flags,omitempty"` + Metric int `json:"metric"` } // LoadAverage represents system load averages. type LoadAverage struct { - OneMin float32 - FiveMin float32 - FifteenMin float32 + OneMin float32 `json:"one_min"` + FiveMin float32 `json:"five_min"` + FifteenMin float32 `json:"fifteen_min"` } // Memory represents memory usage information. type Memory struct { - Total int - Used int - Free int + Total int `json:"total"` + Used int `json:"used"` + Free int `json:"free"` } // OSInfo represents operating system information. type OSInfo struct { - Distribution string - Version string + Distribution string `json:"distribution"` + Version string `json:"version"` } // agentFromGen converts a gen.AgentInfo to an Agent. diff --git a/pkg/sdk/client/audit.go b/pkg/sdk/client/audit.go index d673dd51..847e140e 100644 --- a/pkg/sdk/client/audit.go +++ b/pkg/sdk/client/audit.go @@ -77,7 +77,7 @@ func (s *AuditService) Get( ) (*Response[AuditEntry], error) { parsedID, err := uuid.Parse(id) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid audit ID: %w", err) } resp, err := s.client.GetAuditLogByIDWithResponse(ctx, parsedID) diff --git a/pkg/sdk/client/audit_types.go b/pkg/sdk/client/audit_types.go index ead6ca1e..8f0848f3 100644 --- a/pkg/sdk/client/audit_types.go +++ b/pkg/sdk/client/audit_types.go @@ -28,22 +28,22 @@ import ( // AuditEntry represents a single audit log entry. type AuditEntry struct { - ID string - Timestamp time.Time - User string - Roles []string - Method string - Path string - ResponseCode int - DurationMs int64 - SourceIP string - OperationID string + ID string `json:"id"` + Timestamp time.Time `json:"timestamp"` + User string `json:"user"` + Roles []string `json:"roles,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + ResponseCode int `json:"response_code"` + DurationMs int64 `json:"duration_ms"` + SourceIP string `json:"source_ip"` + OperationID string `json:"operation_id,omitempty"` } // AuditList is a paginated list of audit entries. type AuditList struct { - Items []AuditEntry - TotalItems int + Items []AuditEntry `json:"items"` + TotalItems int `json:"total_items"` } // auditEntryFromGen converts a gen.AuditEntry to an AuditEntry. diff --git a/pkg/sdk/client/docker.go b/pkg/sdk/client/docker.go index 5e8cc585..05f4c3cd 100644 --- a/pkg/sdk/client/docker.go +++ b/pkg/sdk/client/docker.go @@ -36,8 +36,30 @@ type DockerService struct { func (s *DockerService) Create( ctx context.Context, hostname string, - body gen.DockerCreateRequest, + opts DockerCreateOpts, ) (*Response[Collection[DockerResult]], error) { + body := gen.DockerCreateRequest{ + Image: opts.Image, + } + if opts.Name != "" { + body.Name = &opts.Name + } + if len(opts.Command) > 0 { + body.Command = &opts.Command + } + if len(opts.Env) > 0 { + body.Env = &opts.Env + } + if len(opts.Ports) > 0 { + body.Ports = &opts.Ports + } + if len(opts.Volumes) > 0 { + body.Volumes = &opts.Volumes + } + if opts.AutoStart != nil { + body.AutoStart = opts.AutoStart + } + resp, err := s.client.PostNodeContainerDockerWithResponse(ctx, hostname, body) if err != nil { return nil, fmt.Errorf("docker create: %w", err) @@ -67,9 +89,21 @@ func (s *DockerService) Create( func (s *DockerService) List( ctx context.Context, hostname string, - params *gen.GetNodeContainerDockerParams, + params *DockerListParams, ) (*Response[Collection[DockerListResult]], error) { - resp, err := s.client.GetNodeContainerDockerWithResponse(ctx, hostname, params) + var genParams *gen.GetNodeContainerDockerParams + if params != nil { + genParams = &gen.GetNodeContainerDockerParams{} + if params.State != "" { + state := gen.GetNodeContainerDockerParamsState(params.State) + genParams.State = &state + } + if params.Limit > 0 { + genParams.Limit = ¶ms.Limit + } + } + + resp, err := s.client.GetNodeContainerDockerWithResponse(ctx, hostname, genParams) if err != nil { return nil, fmt.Errorf("docker list: %w", err) } @@ -163,8 +197,13 @@ func (s *DockerService) Stop( ctx context.Context, hostname string, id string, - body gen.DockerStopRequest, + opts DockerStopOpts, ) (*Response[Collection[DockerActionResult]], error) { + body := gen.DockerStopRequest{} + if opts.Timeout > 0 { + body.Timeout = &opts.Timeout + } + resp, err := s.client.PostNodeContainerDockerStopWithResponse(ctx, hostname, id, body) if err != nil { return nil, fmt.Errorf("docker stop: %w", err) @@ -196,9 +235,16 @@ func (s *DockerService) Remove( ctx context.Context, hostname string, id string, - params *gen.DeleteNodeContainerDockerByIDParams, + params *DockerRemoveParams, ) (*Response[Collection[DockerActionResult]], error) { - resp, err := s.client.DeleteNodeContainerDockerByIDWithResponse(ctx, hostname, id, params) + var genParams *gen.DeleteNodeContainerDockerByIDParams + if params != nil { + genParams = &gen.DeleteNodeContainerDockerByIDParams{ + Force: ¶ms.Force, + } + } + + resp, err := s.client.DeleteNodeContainerDockerByIDWithResponse(ctx, hostname, id, genParams) if err != nil { return nil, fmt.Errorf("docker remove: %w", err) } @@ -229,8 +275,18 @@ func (s *DockerService) Exec( ctx context.Context, hostname string, id string, - body gen.DockerExecRequest, + opts DockerExecOpts, ) (*Response[Collection[DockerExecResult]], error) { + body := gen.DockerExecRequest{ + Command: opts.Command, + } + if len(opts.Env) > 0 { + body.Env = &opts.Env + } + if opts.WorkingDir != "" { + body.WorkingDir = &opts.WorkingDir + } + resp, err := s.client.PostNodeContainerDockerExecWithResponse(ctx, hostname, id, body) if err != nil { return nil, fmt.Errorf("docker exec: %w", err) @@ -261,8 +317,12 @@ func (s *DockerService) Exec( func (s *DockerService) Pull( ctx context.Context, hostname string, - body gen.DockerPullRequest, + opts DockerPullOpts, ) (*Response[Collection[DockerPullResult]], error) { + body := gen.DockerPullRequest{ + Image: opts.Image, + } + resp, err := s.client.PostNodeContainerDockerPullWithResponse(ctx, hostname, body) if err != nil { return nil, fmt.Errorf("docker pull: %w", err) diff --git a/pkg/sdk/client/docker_public_test.go b/pkg/sdk/client/docker_public_test.go index 57bb154b..bee0b4c6 100644 --- a/pkg/sdk/client/docker_public_test.go +++ b/pkg/sdk/client/docker_public_test.go @@ -31,7 +31,6 @@ import ( "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" ) type DockerPublicTestSuite struct { @@ -45,10 +44,13 @@ func (suite *DockerPublicTestSuite) SetupTest() { } func (suite *DockerPublicTestSuite) TestCreate() { + autoStart := true + tests := []struct { name string handler http.HandlerFunc serverURL string + opts client.DockerCreateOpts validateFunc func(*client.Response[client.Collection[client.DockerResult]], error) }{ { @@ -62,6 +64,10 @@ func (suite *DockerPublicTestSuite) TestCreate() { ), ) }, + opts: client.DockerCreateOpts{ + Image: "nginx:latest", + Name: "my-nginx", + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerResult]], err error, @@ -79,6 +85,38 @@ func (suite *DockerPublicTestSuite) TestCreate() { suite.True(resp.Data.Results[0].Changed) }, }, + { + name: "when creating container with all optional fields returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"web-01","id":"def456","name":"my-app","image":"myapp:v1","state":"running","created":"2026-01-01T00:00:00Z","changed":true}]}`, + ), + ) + }, + opts: client.DockerCreateOpts{ + Image: "myapp:v1", + Name: "my-app", + Command: []string{"serve", "--port", "8080"}, + Env: []string{"FOO=bar", "BAZ=qux"}, + Ports: []string{"8080:80", "443:443"}, + Volumes: []string{"/host/data:/data", "/host/config:/config"}, + AutoStart: &autoStart, + }, + validateFunc: func( + resp *client.Response[client.Collection[client.DockerResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000002", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("def456", resp.Data.Results[0].ID) + suite.Equal("my-app", resp.Data.Results[0].Name) + }, + }, { name: "when server returns 403 returns AuthError", handler: func(w http.ResponseWriter, _ *http.Request) { @@ -86,6 +124,9 @@ func (suite *DockerPublicTestSuite) TestCreate() { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden"}`)) }, + opts: client.DockerCreateOpts{ + Image: "nginx:latest", + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerResult]], err error, @@ -101,6 +142,9 @@ func (suite *DockerPublicTestSuite) TestCreate() { { name: "when client HTTP call fails returns error", serverURL: "http://127.0.0.1:0", + opts: client.DockerCreateOpts{ + Image: "nginx:latest", + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerResult]], err error, @@ -115,6 +159,9 @@ func (suite *DockerPublicTestSuite) TestCreate() { handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) }, + opts: client.DockerCreateOpts{ + Image: "nginx:latest", + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerResult]], err error, @@ -156,10 +203,7 @@ func (suite *DockerPublicTestSuite) TestCreate() { resp, err := sut.Docker.Create( suite.ctx, "_any", - gen.DockerCreateRequest{ - Image: "nginx:latest", - Name: strPtr("my-nginx"), - }, + tc.opts, ) tc.validateFunc(resp, err) }) @@ -171,6 +215,7 @@ func (suite *DockerPublicTestSuite) TestList() { name string handler http.HandlerFunc serverURL string + params *client.DockerListParams validateFunc func(*client.Response[client.Collection[client.DockerListResult]], error) }{ { @@ -198,6 +243,34 @@ func (suite *DockerPublicTestSuite) TestList() { suite.Equal("my-nginx", resp.Data.Results[0].Containers[0].Name) }, }, + { + name: "when listing containers with state and limit params returns results", + handler: func(w http.ResponseWriter, r *http.Request) { + suite.Equal("running", r.URL.Query().Get("state")) + suite.Equal("5", r.URL.Query().Get("limit")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"web-01","containers":[{"id":"abc123","name":"my-nginx","image":"nginx:latest","state":"running","created":"2026-01-01T00:00:00Z"}]}]}`, + ), + ) + }, + params: &client.DockerListParams{ + State: "running", + Limit: 5, + }, + validateFunc: func( + resp *client.Response[client.Collection[client.DockerListResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000002", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + }, + }, { name: "when server returns 403 returns AuthError", handler: func(w http.ResponseWriter, _ *http.Request) { @@ -272,7 +345,7 @@ func (suite *DockerPublicTestSuite) TestList() { client.WithLogger(slog.Default()), ) - resp, err := sut.Docker.List(suite.ctx, "_any", nil) + resp, err := sut.Docker.List(suite.ctx, "_any", tc.params) tc.validateFunc(resp, err) }) } @@ -554,6 +627,7 @@ func (suite *DockerPublicTestSuite) TestStop() { name string handler http.HandlerFunc serverURL string + opts client.DockerStopOpts validateFunc func(*client.Response[client.Collection[client.DockerActionResult]], error) }{ { @@ -581,6 +655,31 @@ func (suite *DockerPublicTestSuite) TestStop() { suite.True(resp.Data.Results[0].Changed) }, }, + { + name: "when stopping container with timeout returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"web-01","id":"abc123","message":"container stopped","changed":true}]}`, + ), + ) + }, + opts: client.DockerStopOpts{ + Timeout: 30, + }, + validateFunc: func( + resp *client.Response[client.Collection[client.DockerActionResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000002", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("abc123", resp.Data.Results[0].ID) + }, + }, { name: "when server returns 404 returns NotFoundError", handler: func(w http.ResponseWriter, _ *http.Request) { @@ -678,7 +777,7 @@ func (suite *DockerPublicTestSuite) TestStop() { suite.ctx, "_any", "abc123", - gen.DockerStopRequest{}, + tc.opts, ) tc.validateFunc(resp, err) }) @@ -690,6 +789,7 @@ func (suite *DockerPublicTestSuite) TestRemove() { name string handler http.HandlerFunc serverURL string + params *client.DockerRemoveParams validateFunc func(*client.Response[client.Collection[client.DockerActionResult]], error) }{ { @@ -717,6 +817,33 @@ func (suite *DockerPublicTestSuite) TestRemove() { suite.True(resp.Data.Results[0].Changed) }, }, + { + name: "when removing container with force returns result", + handler: func(w http.ResponseWriter, r *http.Request) { + suite.Equal("true", r.URL.Query().Get("force")) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"web-01","id":"abc123","message":"container removed","changed":true}]}`, + ), + ) + }, + params: &client.DockerRemoveParams{ + Force: true, + }, + validateFunc: func( + resp *client.Response[client.Collection[client.DockerActionResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000002", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("abc123", resp.Data.Results[0].ID) + }, + }, { name: "when server returns 404 returns NotFoundError", handler: func(w http.ResponseWriter, _ *http.Request) { @@ -810,7 +937,7 @@ func (suite *DockerPublicTestSuite) TestRemove() { client.WithLogger(slog.Default()), ) - resp, err := sut.Docker.Remove(suite.ctx, "_any", "abc123", nil) + resp, err := sut.Docker.Remove(suite.ctx, "_any", "abc123", tc.params) tc.validateFunc(resp, err) }) } @@ -821,6 +948,7 @@ func (suite *DockerPublicTestSuite) TestExec() { name string handler http.HandlerFunc serverURL string + opts client.DockerExecOpts validateFunc func(*client.Response[client.Collection[client.DockerExecResult]], error) }{ { @@ -834,6 +962,9 @@ func (suite *DockerPublicTestSuite) TestExec() { ), ) }, + opts: client.DockerExecOpts{ + Command: []string{"echo", "hello"}, + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerExecResult]], err error, @@ -849,6 +980,33 @@ func (suite *DockerPublicTestSuite) TestExec() { suite.True(resp.Data.Results[0].Changed) }, }, + { + name: "when executing command with env and working dir returns result", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"web-01","stdout":"bar\n","stderr":"","exit_code":0,"changed":true}]}`, + ), + ) + }, + opts: client.DockerExecOpts{ + Command: []string{"printenv", "FOO"}, + Env: []string{"FOO=bar", "BAZ=qux"}, + WorkingDir: "/app", + }, + validateFunc: func( + resp *client.Response[client.Collection[client.DockerExecResult]], + err error, + ) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000002", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("bar\n", resp.Data.Results[0].Stdout) + }, + }, { name: "when server returns 404 returns NotFoundError", handler: func(w http.ResponseWriter, _ *http.Request) { @@ -856,6 +1014,9 @@ func (suite *DockerPublicTestSuite) TestExec() { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"error":"container not found"}`)) }, + opts: client.DockerExecOpts{ + Command: []string{"echo", "hello"}, + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerExecResult]], err error, @@ -875,6 +1036,9 @@ func (suite *DockerPublicTestSuite) TestExec() { w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(`{"error":"forbidden"}`)) }, + opts: client.DockerExecOpts{ + Command: []string{"echo", "hello"}, + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerExecResult]], err error, @@ -890,6 +1054,9 @@ func (suite *DockerPublicTestSuite) TestExec() { { name: "when client HTTP call fails returns error", serverURL: "http://127.0.0.1:0", + opts: client.DockerExecOpts{ + Command: []string{"echo", "hello"}, + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerExecResult]], err error, @@ -904,6 +1071,9 @@ func (suite *DockerPublicTestSuite) TestExec() { handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusAccepted) }, + opts: client.DockerExecOpts{ + Command: []string{"echo", "hello"}, + }, validateFunc: func( resp *client.Response[client.Collection[client.DockerExecResult]], err error, @@ -946,9 +1116,7 @@ func (suite *DockerPublicTestSuite) TestExec() { suite.ctx, "_any", "abc123", - gen.DockerExecRequest{ - Command: []string{"echo", "hello"}, - }, + tc.opts, ) tc.validateFunc(resp, err) }) @@ -1065,7 +1233,7 @@ func (suite *DockerPublicTestSuite) TestPull() { resp, err := sut.Docker.Pull( suite.ctx, "_any", - gen.DockerPullRequest{ + client.DockerPullOpts{ Image: "nginx:latest", }, ) @@ -1074,12 +1242,6 @@ func (suite *DockerPublicTestSuite) TestPull() { } } -func strPtr( - s string, -) *string { - return &s -} - func TestDockerPublicTestSuite(t *testing.T) { suite.Run(t, new(DockerPublicTestSuite)) } diff --git a/pkg/sdk/client/docker_types.go b/pkg/sdk/client/docker_types.go index 248531fb..862a1d7b 100644 --- a/pkg/sdk/client/docker_types.go +++ b/pkg/sdk/client/docker_types.go @@ -24,79 +24,133 @@ import ( "github.com/retr0h/osapi/pkg/sdk/client/gen" ) +// DockerCreateOpts contains options for creating a container. +type DockerCreateOpts struct { + // Image is the container image reference (required). + Image string + // Name is an optional container name. + Name string + // Command overrides the image's default command. + Command []string + // Env is environment variables in KEY=VALUE format. + Env []string + // Ports is port mappings in host_port:container_port format. + Ports []string + // Volumes is volume mounts in host_path:container_path format. + Volumes []string + // AutoStart starts the container after creation (default true). + AutoStart *bool +} + +// DockerStopOpts contains options for stopping a container. +type DockerStopOpts struct { + // Timeout is seconds to wait before killing. Zero uses default. + Timeout int +} + +// DockerListParams contains parameters for listing containers. +type DockerListParams struct { + // State filters by state: "running", "stopped", "all". + State string + // Limit caps the number of results. + Limit int +} + +// DockerRemoveParams contains parameters for removing a container. +type DockerRemoveParams struct { + // Force forces removal of a running container. + Force bool +} + +// DockerPullOpts contains options for pulling an image. +type DockerPullOpts struct { + // Image is the image reference to pull (required). + Image string +} + +// DockerExecOpts contains options for executing a command in a container. +type DockerExecOpts struct { + // Command is the command and arguments to execute (required). + Command []string + // Env is additional environment variables in KEY=VALUE format. + Env []string + // WorkingDir is the working directory inside the container. + WorkingDir string +} + // DockerResult represents a docker container create result from a single agent. type DockerResult struct { - Hostname string - ID string - Name string - Image string - State string - Created string - Changed bool - Error string + Hostname string `json:"hostname"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + State string `json:"state,omitempty"` + Created string `json:"created,omitempty"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // DockerListResult represents a docker container list result from a single agent. type DockerListResult struct { - Hostname string - Containers []DockerSummaryItem - Changed bool - Error string + Hostname string `json:"hostname"` + Containers []DockerSummaryItem `json:"containers,omitempty"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // DockerSummaryItem represents a brief docker container summary. type DockerSummaryItem struct { - ID string - Name string - Image string - State string - Created string + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Created string `json:"created"` } // DockerDetailResult represents a docker container inspect result from a single agent. type DockerDetailResult struct { - Hostname string - ID string - Name string - Image string - State string - Created string - Ports []string - Mounts []string - Env []string - NetworkSettings map[string]string - Health string - Changed bool - Error string + Hostname string `json:"hostname"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Image string `json:"image,omitempty"` + State string `json:"state,omitempty"` + Created string `json:"created,omitempty"` + Ports []string `json:"ports,omitempty"` + Mounts []string `json:"mounts,omitempty"` + Env []string `json:"env,omitempty"` + NetworkSettings map[string]string `json:"network_settings,omitempty"` + Health string `json:"health,omitempty"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // DockerActionResult represents a docker container lifecycle action result from a single agent. type DockerActionResult struct { - Hostname string - ID string - Message string - Changed bool - Error string + Hostname string `json:"hostname"` + ID string `json:"id,omitempty"` + Message string `json:"message,omitempty"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // DockerExecResult represents a docker container exec result from a single agent. type DockerExecResult struct { - Hostname string - Stdout string - Stderr string - ExitCode int - Changed bool - Error string + Hostname string `json:"hostname"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + ExitCode int `json:"exit_code"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // DockerPullResult represents a docker image pull result from a single agent. type DockerPullResult struct { - Hostname string - ImageID string - Tag string - Size int64 - Changed bool - Error string + Hostname string `json:"hostname"` + ImageID string `json:"image_id,omitempty"` + Tag string `json:"tag,omitempty"` + Size int64 `json:"size"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // dockerResultCollectionFromGen converts a gen.DockerResultCollectionResponse diff --git a/pkg/sdk/client/file_types.go b/pkg/sdk/client/file_types.go index f49e1961..17ee172d 100644 --- a/pkg/sdk/client/file_types.go +++ b/pkg/sdk/client/file_types.go @@ -24,64 +24,64 @@ import "github.com/retr0h/osapi/pkg/sdk/client/gen" // FileUpload represents a successfully uploaded file. type FileUpload struct { - Name string - SHA256 string - Size int - Changed bool - ContentType string + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int `json:"size"` + Changed bool `json:"changed"` + ContentType string `json:"content_type"` } // FileItem represents file metadata in a list. type FileItem struct { - Name string - SHA256 string - Size int - ContentType string + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int `json:"size"` + ContentType string `json:"content_type"` } // FileList is a collection of files with total count. type FileList struct { - Files []FileItem - Total int + Files []FileItem `json:"files"` + Total int `json:"total"` } // FileMetadata represents metadata for a single file. type FileMetadata struct { - Name string - SHA256 string - Size int - ContentType string + Name string `json:"name"` + SHA256 string `json:"sha256"` + Size int `json:"size"` + ContentType string `json:"content_type"` } // FileDelete represents the result of a file deletion. type FileDelete struct { - Name string - Deleted bool + Name string `json:"name"` + Deleted bool `json:"deleted"` } // FileChanged represents the result of a change detection check. type FileChanged struct { - Name string - Changed bool - SHA256 string + Name string `json:"name"` + Changed bool `json:"changed"` + SHA256 string `json:"sha256"` } // FileDeployResult represents the result of a file deploy operation. type FileDeployResult struct { - JobID string - Hostname string - Changed bool + JobID string `json:"job_id"` + Hostname string `json:"hostname"` + Changed bool `json:"changed"` } // FileStatusResult represents the result of a file status check. type FileStatusResult struct { - JobID string - Hostname string - Path string - Status string - SHA256 string - Changed bool - Error string + JobID string `json:"job_id"` + Hostname string `json:"hostname"` + Path string `json:"path"` + Status string `json:"status"` + SHA256 string `json:"sha256,omitempty"` + Changed bool `json:"changed"` + Error string `json:"error,omitempty"` } // fileUploadFromGen converts a gen.FileUploadResponse to a FileUpload. diff --git a/pkg/sdk/client/health_types.go b/pkg/sdk/client/health_types.go index 3a3dde13..fae02a91 100644 --- a/pkg/sdk/client/health_types.go +++ b/pkg/sdk/client/health_types.go @@ -24,101 +24,101 @@ import "github.com/retr0h/osapi/pkg/sdk/client/gen" // HealthStatus represents a liveness check response. type HealthStatus struct { - Status string + Status string `json:"status"` } // ReadyStatus represents a readiness check response. type ReadyStatus struct { - Status string - Error string - ServiceUnavailable bool + Status string `json:"status"` + Error string `json:"error,omitempty"` + ServiceUnavailable bool `json:"service_unavailable"` } // SystemStatus represents detailed system status. type SystemStatus struct { - Status string - Version string - Uptime string - ServiceUnavailable bool - Components map[string]ComponentHealth - NATS *NATSInfo - Agents *AgentStats - Jobs *JobStats - Consumers *ConsumerStats - Streams []StreamInfo - KVBuckets []KVBucketInfo - ObjectStores []ObjectStoreInfo + Status string `json:"status"` + Version string `json:"version"` + Uptime string `json:"uptime"` + ServiceUnavailable bool `json:"service_unavailable"` + Components map[string]ComponentHealth `json:"components,omitempty"` + NATS *NATSInfo `json:"nats,omitempty"` + Agents *AgentStats `json:"agents,omitempty"` + Jobs *JobStats `json:"jobs,omitempty"` + Consumers *ConsumerStats `json:"consumers,omitempty"` + Streams []StreamInfo `json:"streams,omitempty"` + KVBuckets []KVBucketInfo `json:"kv_buckets,omitempty"` + ObjectStores []ObjectStoreInfo `json:"object_stores,omitempty"` } // ComponentHealth represents a component's health. type ComponentHealth struct { - Status string - Error string + Status string `json:"status"` + Error string `json:"error,omitempty"` } // NATSInfo represents NATS connection info. type NATSInfo struct { - URL string - Version string + URL string `json:"url"` + Version string `json:"version"` } // AgentStats represents agent statistics from the health endpoint. type AgentStats struct { - Total int - Ready int - Agents []AgentSummary + Total int `json:"total"` + Ready int `json:"ready"` + Agents []AgentSummary `json:"agents,omitempty"` } // AgentSummary represents a summary of an agent from the health endpoint. type AgentSummary struct { - Hostname string - Labels string - Registered string + Hostname string `json:"hostname"` + Labels string `json:"labels,omitempty"` + Registered string `json:"registered"` } // JobStats represents job queue statistics from the health endpoint. type JobStats struct { - Total int - Completed int - Failed int - Processing int - Unprocessed int - Dlq int + Total int `json:"total"` + Completed int `json:"completed"` + Failed int `json:"failed"` + Processing int `json:"processing"` + Unprocessed int `json:"unprocessed"` + Dlq int `json:"dlq"` } // ConsumerStats represents JetStream consumer statistics. type ConsumerStats struct { - Total int - Consumers []ConsumerDetail + Total int `json:"total"` + Consumers []ConsumerDetail `json:"consumers,omitempty"` } // ConsumerDetail represents a single consumer's details. type ConsumerDetail struct { - Name string - Pending int - AckPending int - Redelivered int + Name string `json:"name"` + Pending int `json:"pending"` + AckPending int `json:"ack_pending"` + Redelivered int `json:"redelivered"` } // StreamInfo represents a JetStream stream's info. type StreamInfo struct { - Name string - Messages int - Bytes int - Consumers int + Name string `json:"name"` + Messages int `json:"messages"` + Bytes int `json:"bytes"` + Consumers int `json:"consumers"` } // KVBucketInfo represents a KV bucket's info. type KVBucketInfo struct { - Name string - Keys int - Bytes int + Name string `json:"name"` + Keys int `json:"keys"` + Bytes int `json:"bytes"` } // ObjectStoreInfo represents an Object Store bucket's info. type ObjectStoreInfo struct { - Name string - Size int + Name string `json:"name"` + Size int `json:"size"` } // healthStatusFromGen converts a gen.HealthResponse to a HealthStatus. diff --git a/pkg/sdk/client/job_types.go b/pkg/sdk/client/job_types.go index bb9c7d11..f092d697 100644 --- a/pkg/sdk/client/job_types.go +++ b/pkg/sdk/client/job_types.go @@ -26,49 +26,49 @@ import ( // JobCreated represents a newly created job response. type JobCreated struct { - JobID string - Status string - Revision int64 - Timestamp string + JobID string `json:"job_id"` + Status string `json:"status"` + Revision int64 `json:"revision"` + Timestamp string `json:"timestamp,omitempty"` } // JobDetail represents a job's full details. type JobDetail struct { - ID string - Status string - Hostname string - Created string - UpdatedAt string - Error string - Changed *bool - Operation map[string]any - Result any - AgentStates map[string]AgentState - Responses map[string]AgentJobResponse - Timeline []TimelineEvent + ID string `json:"id,omitempty"` + Status string `json:"status,omitempty"` + Hostname string `json:"hostname,omitempty"` + Created string `json:"created,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Error string `json:"error,omitempty"` + Changed *bool `json:"changed,omitempty"` + Operation map[string]any `json:"operation,omitempty"` + Result any `json:"result,omitempty"` + AgentStates map[string]AgentState `json:"agent_states,omitempty"` + Responses map[string]AgentJobResponse `json:"responses,omitempty"` + Timeline []TimelineEvent `json:"timeline,omitempty"` } // AgentState represents an agent's processing state for a broadcast job. type AgentState struct { - Status string - Duration string - Error string + Status string `json:"status,omitempty"` + Duration string `json:"duration,omitempty"` + Error string `json:"error,omitempty"` } // AgentJobResponse represents an agent's response data for a broadcast job. type AgentJobResponse struct { - Hostname string - Status string - Error string - Changed *bool - Data any + Hostname string `json:"hostname,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` + Changed *bool `json:"changed,omitempty"` + Data any `json:"data,omitempty"` } // JobList is a paginated list of jobs. type JobList struct { - Items []JobDetail - TotalItems int - StatusCounts map[string]int + Items []JobDetail `json:"items,omitempty"` + TotalItems int `json:"total_items"` + StatusCounts map[string]int `json:"status_counts,omitempty"` } // jobCreatedFromGen converts a gen.CreateJobResponse to a JobCreated. diff --git a/pkg/sdk/client/node_types.go b/pkg/sdk/client/node_types.go index fc09f5e9..be0587ba 100644 --- a/pkg/sdk/client/node_types.go +++ b/pkg/sdk/client/node_types.go @@ -28,117 +28,129 @@ import ( // Collection is a generic wrapper for collection responses from node queries. type Collection[T any] struct { - Results []T - JobID string + Results []T `json:"results"` + JobID string `json:"job_id"` +} + +// First returns the first result and true, or the zero value and +// false if the collection is empty. +func (c Collection[T]) First() (T, bool) { + if len(c.Results) == 0 { + var zero T + + return zero, false + } + + return c.Results[0], true } // Disk represents disk usage information. type Disk struct { - Name string - Total int - Used int - Free int + Name string `json:"name"` + Total int `json:"total"` + Used int `json:"used"` + Free int `json:"free"` } // HostnameResult represents a hostname query result from a single agent. type HostnameResult struct { - Hostname string - Error string - Changed bool - Labels map[string]string + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Labels map[string]string `json:"labels,omitempty"` } // NodeStatus represents full node status from a single agent. type NodeStatus struct { - Hostname string - Uptime string - Error string - Changed bool - Disks []Disk - LoadAverage *LoadAverage - Memory *Memory - OSInfo *OSInfo + Hostname string `json:"hostname"` + Uptime string `json:"uptime,omitempty"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Disks []Disk `json:"disks,omitempty"` + LoadAverage *LoadAverage `json:"load_average,omitempty"` + Memory *Memory `json:"memory,omitempty"` + OSInfo *OSInfo `json:"os_info,omitempty"` } // DiskResult represents disk query result from a single agent. type DiskResult struct { - Hostname string - Error string - Changed bool - Disks []Disk + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Disks []Disk `json:"disks,omitempty"` } // MemoryResult represents memory query result from a single agent. type MemoryResult struct { - Hostname string - Error string - Changed bool - Memory *Memory + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Memory *Memory `json:"memory,omitempty"` } // LoadResult represents load average query result from a single agent. type LoadResult struct { - Hostname string - Error string - Changed bool - LoadAverage *LoadAverage + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + LoadAverage *LoadAverage `json:"load_average,omitempty"` } // OSInfoResult represents OS info query result from a single agent. type OSInfoResult struct { - Hostname string - Error string - Changed bool - OSInfo *OSInfo + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + OSInfo *OSInfo `json:"os_info,omitempty"` } // UptimeResult represents uptime query result from a single agent. type UptimeResult struct { - Hostname string - Uptime string - Error string - Changed bool + Hostname string `json:"hostname"` + Uptime string `json:"uptime,omitempty"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` } // DNSConfig represents DNS configuration from a single agent. type DNSConfig struct { - Hostname string - Error string - Changed bool - Servers []string - SearchDomains []string + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + Servers []string `json:"servers,omitempty"` + SearchDomains []string `json:"search_domains,omitempty"` } // DNSUpdateResult represents DNS update result from a single agent. type DNSUpdateResult struct { - Hostname string - Status string - Error string - Changed bool + Hostname string `json:"hostname"` + Status string `json:"status"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` } // PingResult represents ping result from a single agent. type PingResult struct { - Hostname string - Error string - Changed bool - PacketsSent int - PacketsReceived int - PacketLoss float64 - MinRtt string - AvgRtt string - MaxRtt string + Hostname string `json:"hostname"` + Error string `json:"error,omitempty"` + Changed bool `json:"changed"` + PacketsSent int `json:"packets_sent"` + PacketsReceived int `json:"packets_received"` + PacketLoss float64 `json:"packet_loss"` + MinRtt string `json:"min_rtt,omitempty"` + AvgRtt string `json:"avg_rtt,omitempty"` + MaxRtt string `json:"max_rtt,omitempty"` } // CommandResult represents command execution result from a single agent. type CommandResult struct { - Hostname string - Stdout string - Stderr string - Error string - ExitCode int - Changed bool - DurationMs int64 + Hostname string `json:"hostname"` + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + Error string `json:"error,omitempty"` + ExitCode int `json:"exit_code"` + Changed bool `json:"changed"` + DurationMs int64 `json:"duration_ms"` } // loadAverageFromGen converts a gen.LoadAverageResponse to a LoadAverage. diff --git a/pkg/sdk/client/node_types_test.go b/pkg/sdk/client/node_types_test.go index 09b13e36..47095421 100644 --- a/pkg/sdk/client/node_types_test.go +++ b/pkg/sdk/client/node_types_test.go @@ -990,6 +990,63 @@ func (suite *NodeTypesTestSuite) TestJobIDFromGen() { } } +func (suite *NodeTypesTestSuite) TestCollectionFirst() { + tests := []struct { + name string + col Collection[HostnameResult] + validateFunc func(HostnameResult, bool) + }{ + { + name: "returns first result and true", + col: Collection[HostnameResult]{ + Results: []HostnameResult{ + {Hostname: "web-01"}, + {Hostname: "web-02"}, + }, + JobID: "job-1", + }, + validateFunc: func( + r HostnameResult, + ok bool, + ) { + suite.True(ok) + suite.Equal("web-01", r.Hostname) + }, + }, + { + name: "returns zero value and false when empty", + col: Collection[HostnameResult]{ + Results: []HostnameResult{}, + }, + validateFunc: func( + r HostnameResult, + ok bool, + ) { + suite.False(ok) + suite.Equal("", r.Hostname) + }, + }, + { + name: "returns zero value and false when nil", + col: Collection[HostnameResult]{}, + validateFunc: func( + r HostnameResult, + ok bool, + ) { + suite.False(ok) + suite.Equal("", r.Hostname) + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + r, ok := tt.col.First() + tt.validateFunc(r, ok) + }) + } +} + func TestNodeTypesTestSuite(t *testing.T) { suite.Run(t, new(NodeTypesTestSuite)) } diff --git a/pkg/sdk/client/types.go b/pkg/sdk/client/types.go index 8fa2cf52..3802e96e 100644 --- a/pkg/sdk/client/types.go +++ b/pkg/sdk/client/types.go @@ -23,9 +23,9 @@ package client // TimelineEvent represents a lifecycle event. Used by both job // timelines and agent state transition history. type TimelineEvent struct { - Timestamp string - Event string - Hostname string - Message string - Error string + Timestamp string `json:"timestamp"` + Event string `json:"event"` + Hostname string `json:"hostname,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` } diff --git a/pkg/sdk/orchestrator/bridge.go b/pkg/sdk/orchestrator/bridge.go index 55881bd7..5f666a49 100644 --- a/pkg/sdk/orchestrator/bridge.go +++ b/pkg/sdk/orchestrator/bridge.go @@ -36,8 +36,13 @@ func StructToMap( // per-host details, and auto-populates HostResult.Data via StructToMap // when the mapper leaves it nil. Changed is true if any host reported // a change. +// +// When rawJSON is non-nil, it is unmarshaled into Result.Data to +// provide the full response for downstream consumers (e.g., guards +// or Results.Decode). Pass resp.RawJSON() for this, or nil to skip. func CollectionResult[T any]( col client.Collection[T], + rawJSON []byte, toHostResult func(T) HostResult, ) *Result { hostResults := make([]HostResult, 0, len(col.Results)) @@ -57,9 +62,15 @@ func CollectionResult[T any]( hostResults = append(hostResults, hr) } + var data map[string]any + if len(rawJSON) > 0 { + _ = jsonUnmarshalFn(rawJSON, &data) + } + return &Result{ JobID: col.JobID, Changed: changed, + Data: data, HostResults: hostResults, } } diff --git a/pkg/sdk/orchestrator/bridge_public_test.go b/pkg/sdk/orchestrator/bridge_public_test.go index 8133b333..cfa24d94 100644 --- a/pkg/sdk/orchestrator/bridge_public_test.go +++ b/pkg/sdk/orchestrator/bridge_public_test.go @@ -66,15 +66,15 @@ func (s *BridgePublicTestSuite) TestStructToMap() { }, }, { - name: "converts struct without json tags using field names", + name: "converts SDK type with json tags", input: client.HostnameResult{ Hostname: "web-01", Changed: true, }, validateFn: func(m map[string]any) { s.Require().NotNil(m) - s.Equal("web-01", m["Hostname"]) - s.Equal(true, m["Changed"]) + s.Equal("web-01", m["hostname"]) + s.Equal(true, m["changed"]) }, }, { @@ -106,6 +106,7 @@ func (s *BridgePublicTestSuite) TestCollectionResult() { tests := []struct { name string col client.Collection[client.HostnameResult] + rawJSON []byte toHost func(client.HostnameResult) orchestrator.HostResult validateFn func(result *orchestrator.Result) }{ @@ -127,7 +128,7 @@ func (s *BridgePublicTestSuite) TestCollectionResult() { s.Equal("web-01", hr.Hostname) s.False(hr.Changed) s.Require().NotNil(hr.Data, "Data should be auto-populated via StructToMap") - s.Equal("web-01", hr.Data["Hostname"]) + s.Equal("web-01", hr.Data["hostname"]) }, }, { @@ -174,14 +175,13 @@ func (s *BridgePublicTestSuite) TestCollectionResult() { Hostname: r.Hostname, Changed: r.Changed, Error: r.Error, - // Data intentionally left nil } }, validateFn: func(result *orchestrator.Result) { hr := result.HostResults[0] s.Require().NotNil(hr.Data) - s.Equal("db-01", hr.Data["Hostname"]) - s.Equal("timeout", hr.Data["Error"]) + s.Equal("db-01", hr.Data["hostname"]) + s.Equal("timeout", hr.Data["error"]) }, }, { @@ -203,16 +203,62 @@ func (s *BridgePublicTestSuite) TestCollectionResult() { hr := result.HostResults[0] s.Require().NotNil(hr.Data) s.Equal("value", hr.Data["custom"]) - // Should NOT contain auto-populated fields - _, hasHostname := hr.Data["Hostname"] + _, hasHostname := hr.Data["hostname"] s.False(hasHostname, "mapper-set Data should not be overwritten") }, }, + { + name: "rawJSON populates Result.Data", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01"}, + }, + JobID: "job-raw", + }, + rawJSON: []byte(`{"job_id":"job-raw","results":[{"hostname":"web-01"}]}`), + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Require().NotNil(result.Data) + s.Equal("job-raw", result.Data["job_id"]) + }, + }, + { + name: "nil rawJSON leaves Result.Data nil", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01"}, + }, + JobID: "job-nil", + }, + rawJSON: nil, + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Nil(result.Data) + }, + }, + { + name: "invalid rawJSON leaves Result.Data nil", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01"}, + }, + JobID: "job-bad", + }, + rawJSON: []byte(`not valid json`), + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Nil(result.Data) + }, + }, } for _, tt := range tests { s.Run(tt.name, func() { - result := orchestrator.CollectionResult(tt.col, tt.toHost) + result := orchestrator.CollectionResult( + tt.col, + tt.rawJSON, + tt.toHost, + ) s.Require().NotNil(result) tt.validateFn(result) })