From 04caabe2ae30c6f4f5d1e331e910a7ddcf7f3ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 11:59:26 -0800 Subject: [PATCH 1/9] docs(sdk): add monorepo migration design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan for moving osapi-sdk into pkg/sdk/ with two incremental PRs, flattened examples, Docusaurus SDK sidebar, and direct spec sharing. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ...026-03-07-sdk-monorepo-migration-design.md | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/plans/2026-03-07-sdk-monorepo-migration-design.md diff --git a/docs/plans/2026-03-07-sdk-monorepo-migration-design.md b/docs/plans/2026-03-07-sdk-monorepo-migration-design.md new file mode 100644 index 00000000..233aff8f --- /dev/null +++ b/docs/plans/2026-03-07-sdk-monorepo-migration-design.md @@ -0,0 +1,182 @@ +# SDK Monorepo Migration + +**Date:** 2026-03-07 **Status:** Design **Author:** @retr0h + +## Problem + +The SDK living in a separate repo (`osapi-io/osapi-sdk`) creates friction: + +- OpenAPI specs must be synced via gilt overlay from osapi's `main` branch +- Every API change requires a two-repo dance: merge osapi, run `just generate` + in the SDK, merge SDK, update `go.mod` in osapi +- Per-example directories each with their own `go.mod` are a maintenance burden +- The SDK has no external consumers — it's only used by the osapi CLI + +## Solution + +Move the SDK into the osapi repo as `pkg/sdk/`. Two incremental PRs. + +## Package Layout + +``` +pkg/sdk/ +├── osapi/ ← PR 1 +│ ├── gen/ +│ │ ├── cfg.yaml ← points to ../../../internal/api/gen/api.yaml +│ │ ├── generate.go ← just oapi-codegen, no gilt +│ │ └── client.gen.go +│ ├── osapi.go +│ ├── transport.go +│ ├── errors.go +│ ├── response.go +│ ├── types.go +│ ├── agent.go +│ ├── agent_types.go +│ ├── audit.go +│ ├── audit_types.go +│ ├── file.go +│ ├── file_types.go +│ ├── health.go +│ ├── health_types.go +│ ├── job.go +│ ├── job_types.go +│ ├── metrics.go +│ ├── node.go +│ ├── node_types.go +│ └── *_test.go +└── orchestrator/ ← PR 2 + ├── plan.go + ├── task.go + ├── options.go + ├── result.go + ├── runner.go + └── *_test.go +``` + +Import paths change to: + +- `github.com/retr0h/osapi/pkg/sdk/osapi` +- `github.com/retr0h/osapi/pkg/sdk/orchestrator` + +## Spec Generation + +No more gilt. The `cfg.yaml` in `pkg/sdk/osapi/gen/` references the server's +combined spec directly: + +```yaml +# cfg.yaml +input: ../../../internal/api/gen/api.yaml +``` + +Single source of truth. Specs can never drift. Regenerate with +`go generate ./pkg/sdk/...`. + +## Examples + +Flatten from per-directory modules to individual files in two directories: + +``` +examples/sdk/ +├── osapi/ +│ ├── go.mod ← replace ../../../pkg/sdk +│ ├── go.sum +│ ├── health.go ← go run health.go +│ ├── node.go +│ ├── agent.go +│ ├── audit.go +│ ├── command.go +│ ├── file.go +│ ├── job.go +│ ├── metrics.go +│ └── network.go +└── orchestrator/ + ├── go.mod ← replace ../../../pkg/sdk + ├── go.sum + ├── basic.go + ├── parallel.go + ├── guards.go + ├── hooks.go + ├── retry.go + ├── broadcast.go + ├── error_strategy.go + ├── file_deploy.go + ├── only_if_changed.go + ├── only_if_failed.go + ├── result_decode.go + ├── task_func.go + └── task_func_results.go +``` + +All files are `package main`. Run with `go run health.go`. + +## Documentation + +### Docusaurus SDK Sidebar + +New top-level sidebar section: + +``` +docs/docs/sidebar/sdk/ +├── sdk.md ← Overview with DocCardList +├── client/ +│ ├── client.md ← Client overview, New(), options, transport +│ ├── agent.md +│ ├── audit.md +│ ├── file.md +│ ├── health.md +│ ├── job.md +│ ├── metrics.md +│ └── node.md +└── orchestrator/ + ├── orchestrator.md ← Overview, Plan/Task/Run + ├── operations.md ← Built-in operations reference + ├── hooks.md ← Hooks and error strategies + └── examples.md ← Example walkthroughs +``` + +Content migrated from the osapi-sdk `docs/osapi/` and `docs/orchestration/` +directories. Landing page uses `` cards. + +### README and CLAUDE.md Updates + +- **README.md**: Add SDK link in the docs/features section. Remove sibling repo + references. +- **CLAUDE.md**: Update SDK references to reflect `pkg/sdk/` location. Simplify + "Adding a New API Domain" Step 5 — no gilt, just + `go generate ./pkg/sdk/...`. Remove sibling repo references but keep SDK + documentation (now pointing to in-repo paths). +- **docusaurus.config.ts**: Add "SDK" to the navbar Features dropdown. + +## Cleanup + +### PR 1 (SDK client) + +- Copy `osapi-sdk/pkg/osapi/` → `pkg/sdk/osapi/` +- Update `pkg/sdk/osapi/gen/cfg.yaml` to reference `internal/api/gen/api.yaml` +- Remove `generate.go` gilt step (oapi-codegen only) +- Flatten `osapi-sdk/examples/osapi/` → `examples/sdk/osapi/` +- Update all `cmd/*.go` imports: + `github.com/osapi-io/osapi-sdk/pkg/osapi` → + `github.com/retr0h/osapi/pkg/sdk/osapi` +- Remove `github.com/osapi-io/osapi-sdk` from `go.mod` +- Create Docusaurus client pages +- Update README.md, CLAUDE.md + +### PR 2 (Orchestrator) + +- Copy `osapi-sdk/pkg/orchestrator/` → `pkg/sdk/orchestrator/` +- Update orchestrator imports to use new SDK client path +- Flatten `osapi-sdk/examples/orchestration/` → `examples/sdk/orchestrator/` +- Create Docusaurus orchestrator pages + +### Post-merge + +- User archives `osapi-io/osapi-sdk` repo on GitHub + +## Scalability Note: `kv.Keys()` + +Not related to this migration but documented here for context — the SDK's +`QueueStats()` and `List()` methods rely on the server's `kv.Keys()` call. See +the [Job Architecture](../docs/sidebar/architecture/job-architecture.md) +performance section for the known scalability constraint and mitigation +approaches. From b80a6ac57d2e1d34eaf4dce1a257784dd6018d4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 12:10:00 -0800 Subject: [PATCH 2/9] docs(sdk): add monorepo migration implementation plan Co-Authored-By: Claude --- .../2026-03-07-sdk-monorepo-migration.md | 527 ++++++++++++++++++ 1 file changed, 527 insertions(+) create mode 100644 docs/plans/2026-03-07-sdk-monorepo-migration.md diff --git a/docs/plans/2026-03-07-sdk-monorepo-migration.md b/docs/plans/2026-03-07-sdk-monorepo-migration.md new file mode 100644 index 00000000..7e34b1ac --- /dev/null +++ b/docs/plans/2026-03-07-sdk-monorepo-migration.md @@ -0,0 +1,527 @@ +# SDK Monorepo Migration (PR 1: Client) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. + +**Goal:** Move the SDK client library from `osapi-io/osapi-sdk` into this repo +as `pkg/sdk/osapi/`, flatten examples, add Docusaurus SDK docs, and update all +references. + +**Architecture:** Copy `osapi-sdk/pkg/osapi/` into `pkg/sdk/osapi/`, rewrite +the codegen to read the server's combined spec directly (no gilt), update all 18 +Go import paths, flatten 9 example directories into individual files, create +Docusaurus SDK sidebar pages, and clean up CLAUDE.md/README.md references. + +**Tech Stack:** Go, oapi-codegen, Docusaurus, Cobra CLI + +**Design doc:** `docs/plans/2026-03-07-sdk-monorepo-migration-design.md` + +--- + +### Task 1: Copy SDK client package + +**Files:** + +- Create: `pkg/sdk/osapi/` (all `.go` files from `osapi-sdk/pkg/osapi/`) +- Create: `pkg/sdk/osapi/gen/` (cfg.yaml, generate.go, client.gen.go) + +**Step 1: Copy source files** + +```bash +mkdir -p pkg/sdk/osapi/gen +cp ../osapi-sdk/pkg/osapi/*.go pkg/sdk/osapi/ +cp ../osapi-sdk/pkg/osapi/gen/client.gen.go pkg/sdk/osapi/gen/ +``` + +**Step 2: Create new cfg.yaml** + +Create `pkg/sdk/osapi/gen/cfg.yaml`: + +```yaml +--- +package: gen +output: client.gen.go +generate: + models: true + client: true +output-options: + skip-prune: true +``` + +**Step 3: Create new generate.go** + +Create `pkg/sdk/osapi/gen/generate.go`: + +```go +// Package gen contains generated code for the OSAPI REST API client. +package gen + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml ../../../internal/api/gen/api.yaml +``` + +No gilt — oapi-codegen reads the server's combined spec directly. + +**Step 4: Update package import paths in all copied files** + +In every `.go` file under `pkg/sdk/osapi/` (non-test, non-gen), replace: + +``` +"github.com/osapi-io/osapi-sdk/pkg/osapi/gen" +``` + +with: + +``` +"github.com/retr0h/osapi/pkg/sdk/osapi/gen" +``` + +In every `_test.go` file under `pkg/sdk/osapi/`, replace: + +``` +"github.com/osapi-io/osapi-sdk/pkg/osapi/gen" +``` + +with: + +``` +"github.com/retr0h/osapi/pkg/sdk/osapi/gen" +``` + +And for public test files, replace: + +``` +"github.com/osapi-io/osapi-sdk/pkg/osapi" +``` + +with: + +``` +"github.com/retr0h/osapi/pkg/sdk/osapi" +``` + +**Step 5: Regenerate client to verify** + +```bash +cd pkg/sdk/osapi/gen && go generate ./... +``` + +Verify `client.gen.go` is regenerated without errors. + +**Step 6: Commit** + +```bash +git add pkg/sdk/ +git commit -m "feat(sdk): copy client library into pkg/sdk/osapi" +``` + +--- + +### Task 2: Update Go imports and remove external SDK dependency + +**Files:** + +- Modify: `go.mod` (remove `github.com/osapi-io/osapi-sdk` require) +- Modify: 18 Go files (update import paths) + +**Step 1: Update all Go imports** + +In every file listed below, replace +`"github.com/osapi-io/osapi-sdk/pkg/osapi"` with +`"github.com/retr0h/osapi/pkg/sdk/osapi"`: + +- `cmd/client.go` +- `cmd/client_agent_get.go` +- `cmd/client_audit_export.go` +- `cmd/client_file_upload.go` (aliased import: `osapi "..."`) +- `cmd/client_health_status.go` +- `cmd/client_job_list.go` +- `cmd/client_job_run.go` +- `cmd/client_node_command_exec.go` +- `cmd/client_node_command_shell.go` +- `cmd/client_node_file_deploy.go` +- `cmd/client_node_status_get.go` +- `internal/audit/export/types.go` +- `internal/audit/export/file.go` +- `internal/audit/export/file_test.go` +- `internal/audit/export/export_public_test.go` +- `internal/audit/export/file_public_test.go` +- `internal/cli/ui.go` +- `internal/cli/ui_public_test.go` + +**Step 2: Remove external SDK from go.mod** + +Remove the `github.com/osapi-io/osapi-sdk` line from the `require` block in +`go.mod`. Then run: + +```bash +go mod tidy +``` + +This will remove the SDK from `go.sum` as well. + +**Step 3: Build and test** + +```bash +go build ./... +go test ./... -count=1 -timeout 120s +``` + +**Step 4: Commit** + +```bash +git add -A +git commit -m "refactor(sdk): update imports to pkg/sdk/osapi" +``` + +--- + +### Task 3: Flatten SDK client examples + +**Files:** + +- Create: `examples/sdk/osapi/go.mod` +- Create: `examples/sdk/osapi/health.go` (and 8 more) + +**Step 1: Create examples directory and go.mod** + +```bash +mkdir -p examples/sdk/osapi +``` + +Create `examples/sdk/osapi/go.mod`: + +``` +module github.com/retr0h/osapi/examples/sdk/osapi + +go 1.25.0 + +replace github.com/retr0h/osapi => ../../../ + +require github.com/retr0h/osapi v0.0.0 +``` + +Then run: + +```bash +cd examples/sdk/osapi && go mod tidy +``` + +**Step 2: Create flattened example files** + +Copy each example's `main.go` into a single file, updating the import path. +Each file is `package main` and self-contained. + +From `../osapi-sdk/examples/osapi/`: + +| Source directory | Target file | +|---|---| +| `health/main.go` | `examples/sdk/osapi/health.go` | +| `node/main.go` | `examples/sdk/osapi/node.go` | +| `agent/main.go` | `examples/sdk/osapi/agent.go` | +| `audit/main.go` | `examples/sdk/osapi/audit.go` | +| `command/main.go` | `examples/sdk/osapi/command.go` | +| `file/main.go` | `examples/sdk/osapi/file.go` | +| `job/main.go` | `examples/sdk/osapi/job.go` | +| `metrics/main.go` | `examples/sdk/osapi/metrics.go` | +| `network/main.go` | `examples/sdk/osapi/network.go` | + +In each file, replace: + +``` +"github.com/osapi-io/osapi-sdk/pkg/osapi" +``` + +with: + +``` +"github.com/retr0h/osapi/pkg/sdk/osapi" +``` + +**Step 3: Verify examples compile** + +```bash +cd examples/sdk/osapi && go build ./... +``` + +Note: `go build ./...` on `package main` files in the same directory will +verify they all compile. They won't run without a live server, but compilation +proves imports are correct. + +**Step 4: Commit** + +```bash +git add examples/sdk/ +git commit -m "feat(sdk): add flattened client examples" +``` + +--- + +### Task 4: Create Docusaurus SDK client pages + +**Files:** + +- Create: `docs/docs/sidebar/sdk/sdk.md` +- Create: `docs/docs/sidebar/sdk/client/client.md` +- Create: `docs/docs/sidebar/sdk/client/agent.md` +- Create: `docs/docs/sidebar/sdk/client/audit.md` +- Create: `docs/docs/sidebar/sdk/client/file.md` +- Create: `docs/docs/sidebar/sdk/client/health.md` +- Create: `docs/docs/sidebar/sdk/client/job.md` +- Create: `docs/docs/sidebar/sdk/client/metrics.md` +- Create: `docs/docs/sidebar/sdk/client/node.md` +- Modify: `docs/docusaurus.config.ts` + +**Step 1: Create SDK landing page** + +Create `docs/docs/sidebar/sdk/sdk.md`: + +```markdown +--- +sidebar_position: 6 +--- + +# SDK + +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. + + +``` + +**Step 2: Create client overview page** + +Create `docs/docs/sidebar/sdk/client/client.md`. Migrate content from +`osapi-sdk/docs/osapi/README.md`: services table, client options, targeting +table. Adapt to Docusaurus format with `` for per-service pages. + +**Step 3: Create per-service pages** + +Create one page per service (`agent.md`, `audit.md`, `file.md`, `health.md`, +`job.md`, `metrics.md`, `node.md`). Migrate content from +`osapi-sdk/docs/osapi/{service}.md`. Each page covers the service methods, +parameters, return types, and a usage example. + +**Step 4: Update docusaurus.config.ts** + +Add "SDK" to the Features navbar dropdown: + +```typescript +{ + label: 'SDK', + to: 'sidebar/sdk/sdk', +}, +``` + +Update the `specPath` for the API docs plugin from +`../../osapi-sdk/pkg/osapi/gen/api.yaml` to +`../internal/api/gen/api.yaml`. + +**Step 5: Update the API docs specPath** + +In `docs/docusaurus.config.ts`, change: + +```typescript +specPath: '../../osapi-sdk/pkg/osapi/gen/api.yaml', +``` + +to: + +```typescript +specPath: '../internal/api/gen/api.yaml', +``` + +And remove the GitHub download URL reference to the SDK repo. + +**Step 6: Verify docs build** + +```bash +cd docs && bun run build +``` + +**Step 7: Commit** + +```bash +git add docs/ +git commit -m "docs(sdk): add client library pages to Docusaurus" +``` + +--- + +### Task 5: Update CLAUDE.md + +**Files:** + +- Modify: `CLAUDE.md` + +**Step 1: Update architecture section** + +Change line ~41 from: + +``` +- **`osapi-sdk`** - External SDK for programmatic REST API access (sibling repo, linked via `replace` in `go.mod`) +``` + +to: + +``` +- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`osapi/` client library, `orchestrator/` DAG runner) +``` + +**Step 2: Rewrite "Update SDK" Step 5** + +Replace the entire Step 5 section (lines ~174-196) with: + +```markdown +### Step 5: Update SDK + +The SDK client library lives in `pkg/sdk/osapi/`. Its generated HTTP client +uses the same combined OpenAPI spec as the server +(`internal/api/gen/api.yaml`). + +**When modifying existing API specs:** + +1. Make changes to `internal/api/{domain}/gen/api.yaml` in this repo +2. Run `just generate` to regenerate server code (this also regenerates the + combined spec via `redocly join`) +3. Run `go generate ./pkg/sdk/osapi/gen/...` to regenerate the SDK client +4. Update the SDK service wrappers in `pkg/sdk/osapi/{domain}.go` if new + response codes were added +5. Update CLI switch blocks in `cmd/` if new response codes were added + +**When adding a new API domain:** + +1. Add a service wrapper in `pkg/sdk/osapi/{domain}.go` +2. Run `go generate ./pkg/sdk/osapi/gen/...` to pick up the new domain's + spec from the combined `api.yaml` +``` + +**Step 3: Remove sibling repo references** + +Remove any remaining references to `osapi-sdk` as a "sibling repo" or +"external" dependency. Keep documentation about the SDK but update paths to +`pkg/sdk/`. + +**Step 4: Commit** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md for in-repo SDK" +``` + +--- + +### Task 6: Update README.md and system-architecture.md + +**Files:** + +- Modify: `README.md` +- Modify: `docs/docs/sidebar/architecture/system-architecture.md` + +**Step 1: Update README.md** + +Replace the "Sister Projects" section. Remove `osapi-sdk` from the sister +projects table (it's now in-repo). Add an SDK link in the Documentation +section: + +```markdown +## 📖 Documentation + +- [Getting Started](https://osapi-io.github.io/osapi/) +- [Features](https://osapi-io.github.io/osapi/sidebar/features/) +- [SDK](https://osapi-io.github.io/osapi/sidebar/sdk/sdk) +- [CLI Reference](https://osapi-io.github.io/osapi/sidebar/usage/) +- [Architecture](https://osapi-io.github.io/osapi/sidebar/architecture/) +``` + +If `osapi-orchestrator` is still a separate repo, keep it in sister projects +but remove `osapi-sdk`. + +**Step 2: Update system-architecture.md** + +Change line ~19 from: + +``` +| **SDK Client** | `osapi-sdk` (external) | OpenAPI-generated client used by CLI | +``` + +to: + +``` +| **SDK Client** | `pkg/sdk/osapi` | OpenAPI-generated client used by CLI | +``` + +Update the mermaid diagram reference from `SDK["SDK Client (osapi-sdk)"]` to +`SDK["SDK Client (pkg/sdk/osapi)"]`. + +**Step 3: Commit** + +```bash +git add README.md docs/docs/sidebar/architecture/system-architecture.md +git commit -m "docs: update README and architecture for in-repo SDK" +``` + +--- + +### Task 7: Final verification + +**Step 1: Full build** + +```bash +go build ./... +``` + +**Step 2: Full test suite** + +```bash +go test ./... -count=1 -timeout 120s +``` + +**Step 3: Lint** + +```bash +just go::vet +``` + +**Step 4: Regenerate to verify codegen pipeline** + +```bash +go generate ./pkg/sdk/osapi/gen/... +go build ./... +``` + +**Step 5: Verify examples compile** + +```bash +cd examples/sdk/osapi && go build ./... +``` + +**Step 6: Verify docs build** + +```bash +cd docs && bun run build +``` + +--- + +## Files Summary + +| Action | Path | +|---|---| +| Create | `pkg/sdk/osapi/*.go` (all source + test files) | +| Create | `pkg/sdk/osapi/gen/cfg.yaml` | +| Create | `pkg/sdk/osapi/gen/generate.go` | +| Create | `pkg/sdk/osapi/gen/client.gen.go` | +| Create | `examples/sdk/osapi/go.mod` | +| Create | `examples/sdk/osapi/*.go` (9 example files) | +| Create | `docs/docs/sidebar/sdk/sdk.md` | +| Create | `docs/docs/sidebar/sdk/client/client.md` | +| Create | `docs/docs/sidebar/sdk/client/{service}.md` (7 files) | +| Modify | `cmd/*.go` (11 files — import path) | +| Modify | `internal/audit/export/*.go` (5 files — import path) | +| Modify | `internal/cli/ui.go`, `ui_public_test.go` (import path) | +| Modify | `go.mod` (remove external SDK) | +| Modify | `CLAUDE.md` | +| Modify | `README.md` | +| Modify | `docs/docs/sidebar/architecture/system-architecture.md` | +| Modify | `docs/docusaurus.config.ts` | From 5cff2ac2b811068278f47e9609cdabd8ad845e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 12:18:43 -0800 Subject: [PATCH 3/9] feat(sdk): copy client library into pkg/sdk/osapi Copy the SDK client package from the external osapi-sdk repo into pkg/sdk/osapi/ as the first step of the monorepo migration. The generate directive reads the combined OpenAPI spec directly from internal/api/gen/api.yaml, eliminating the gilt overlay. Fix .gitignore to use /osapi (root-only) instead of osapi which was inadvertently ignoring any directory named osapi. Co-Authored-By: Claude --- .gitignore | 2 +- pkg/sdk/osapi/agent.go | 142 + pkg/sdk/osapi/agent_public_test.go | 438 ++ pkg/sdk/osapi/agent_types.go | 290 ++ pkg/sdk/osapi/agent_types_test.go | 325 ++ pkg/sdk/osapi/audit.go | 134 + pkg/sdk/osapi/audit_public_test.go | 341 ++ pkg/sdk/osapi/audit_types.go | 85 + pkg/sdk/osapi/audit_types_test.go | 233 + pkg/sdk/osapi/errors.go | 98 + pkg/sdk/osapi/errors_public_test.go | 367 ++ pkg/sdk/osapi/file.go | 270 ++ pkg/sdk/osapi/file_public_test.go | 789 +++ pkg/sdk/osapi/file_types.go | 167 + pkg/sdk/osapi/file_types_test.go | 264 + pkg/sdk/osapi/gen/cfg.yaml | 8 + pkg/sdk/osapi/gen/client.gen.go | 6438 +++++++++++++++++++++++++ pkg/sdk/osapi/gen/generate.go | 24 + pkg/sdk/osapi/health.go | 133 + pkg/sdk/osapi/health_public_test.go | 373 ++ pkg/sdk/osapi/health_types.go | 286 ++ pkg/sdk/osapi/health_types_test.go | 351 ++ pkg/sdk/osapi/job.go | 226 + pkg/sdk/osapi/job_public_test.go | 691 +++ pkg/sdk/osapi/job_types.go | 262 + pkg/sdk/osapi/job_types_test.go | 361 ++ pkg/sdk/osapi/metrics.go | 66 + pkg/sdk/osapi/metrics_public_test.go | 137 + pkg/sdk/osapi/metrics_test.go | 103 + pkg/sdk/osapi/node.go | 512 ++ pkg/sdk/osapi/node_public_test.go | 1693 +++++++ pkg/sdk/osapi/node_types.go | 501 ++ pkg/sdk/osapi/node_types_test.go | 974 ++++ pkg/sdk/osapi/osapi.go | 140 + pkg/sdk/osapi/osapi_public_test.go | 101 + pkg/sdk/osapi/response.go | 95 + pkg/sdk/osapi/response_public_test.go | 103 + pkg/sdk/osapi/response_test.go | 208 + pkg/sdk/osapi/transport.go | 67 + pkg/sdk/osapi/transport_test.go | 80 + pkg/sdk/osapi/types.go | 31 + 41 files changed, 17908 insertions(+), 1 deletion(-) create mode 100644 pkg/sdk/osapi/agent.go create mode 100644 pkg/sdk/osapi/agent_public_test.go create mode 100644 pkg/sdk/osapi/agent_types.go create mode 100644 pkg/sdk/osapi/agent_types_test.go create mode 100644 pkg/sdk/osapi/audit.go create mode 100644 pkg/sdk/osapi/audit_public_test.go create mode 100644 pkg/sdk/osapi/audit_types.go create mode 100644 pkg/sdk/osapi/audit_types_test.go create mode 100644 pkg/sdk/osapi/errors.go create mode 100644 pkg/sdk/osapi/errors_public_test.go create mode 100644 pkg/sdk/osapi/file.go create mode 100644 pkg/sdk/osapi/file_public_test.go create mode 100644 pkg/sdk/osapi/file_types.go create mode 100644 pkg/sdk/osapi/file_types_test.go create mode 100644 pkg/sdk/osapi/gen/cfg.yaml create mode 100644 pkg/sdk/osapi/gen/client.gen.go create mode 100644 pkg/sdk/osapi/gen/generate.go create mode 100644 pkg/sdk/osapi/health.go create mode 100644 pkg/sdk/osapi/health_public_test.go create mode 100644 pkg/sdk/osapi/health_types.go create mode 100644 pkg/sdk/osapi/health_types_test.go create mode 100644 pkg/sdk/osapi/job.go create mode 100644 pkg/sdk/osapi/job_public_test.go create mode 100644 pkg/sdk/osapi/job_types.go create mode 100644 pkg/sdk/osapi/job_types_test.go create mode 100644 pkg/sdk/osapi/metrics.go create mode 100644 pkg/sdk/osapi/metrics_public_test.go create mode 100644 pkg/sdk/osapi/metrics_test.go create mode 100644 pkg/sdk/osapi/node.go create mode 100644 pkg/sdk/osapi/node_public_test.go create mode 100644 pkg/sdk/osapi/node_types.go create mode 100644 pkg/sdk/osapi/node_types_test.go create mode 100644 pkg/sdk/osapi/osapi.go create mode 100644 pkg/sdk/osapi/osapi_public_test.go create mode 100644 pkg/sdk/osapi/response.go create mode 100644 pkg/sdk/osapi/response_public_test.go create mode 100644 pkg/sdk/osapi/response_test.go create mode 100644 pkg/sdk/osapi/transport.go create mode 100644 pkg/sdk/osapi/transport_test.go create mode 100644 pkg/sdk/osapi/types.go diff --git a/.gitignore b/.gitignore index 2abfd092..0be67693 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ result.xml test/vendor/ tmp/ -osapi +/osapi diff --git a/pkg/sdk/osapi/agent.go b/pkg/sdk/osapi/agent.go new file mode 100644 index 00000000..6968a1d3 --- /dev/null +++ b/pkg/sdk/osapi/agent.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// MessageResponse represents a simple message response from the API. +type MessageResponse struct { + Message string +} + +// AgentService provides agent discovery and details operations. +type AgentService struct { + client *gen.ClientWithResponses +} + +// List retrieves all active agents. +func (s *AgentService) List( + ctx context.Context, +) (*Response[AgentList], error) { + resp, err := s.client.GetAgentWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("list agents: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(agentListFromGen(resp.JSON200), resp.Body), nil +} + +// Get retrieves detailed information about a specific agent by hostname. +func (s *AgentService) Get( + ctx context.Context, + hostname string, +) (*Response[Agent], error) { + resp, err := s.client.GetAgentDetailsWithResponse(ctx, hostname) + if err != nil { + return nil, fmt.Errorf("get agent %s: %w", hostname, err) + } + + if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(agentFromGen(resp.JSON200), resp.Body), nil +} + +// Drain initiates draining of an agent, stopping it from accepting +// new jobs while allowing in-flight jobs to complete. +func (s *AgentService) Drain( + ctx context.Context, + hostname string, +) (*Response[MessageResponse], error) { + resp, err := s.client.DrainAgentWithResponse(ctx, hostname) + if err != nil { + return nil, fmt.Errorf("drain agent %s: %w", hostname, err) + } + + if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + msg := MessageResponse{ + Message: resp.JSON200.Message, + } + + return NewResponse(msg, resp.Body), nil +} + +// Undrain resumes job acceptance on a drained agent. +func (s *AgentService) Undrain( + ctx context.Context, + hostname string, +) (*Response[MessageResponse], error) { + resp, err := s.client.UndrainAgentWithResponse(ctx, hostname) + if err != nil { + return nil, fmt.Errorf("undrain agent %s: %w", hostname, err) + } + + if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON409); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + msg := MessageResponse{ + Message: resp.JSON200.Message, + } + + return NewResponse(msg, resp.Body), nil +} diff --git a/pkg/sdk/osapi/agent_public_test.go b/pkg/sdk/osapi/agent_public_test.go new file mode 100644 index 00000000..989cd57f --- /dev/null +++ b/pkg/sdk/osapi/agent_public_test.go @@ -0,0 +1,438 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type AgentPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *AgentPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *AgentPublicTestSuite) TestList() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.AgentList], error) + }{ + { + name: "when requesting agents returns no error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"agents":[],"total":0}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal(0, resp.Data.Total) + suite.Empty(resp.Data.Agents) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when client HTTP error returns wrapped error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "list agents") + }, + }, + { + name: "when response JSON200 is nil returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + url = server.URL + } + + sut := osapi.New( + url, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Agent.List(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *AgentPublicTestSuite) TestGet() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + hostname string + validateFunc func(*osapi.Response[osapi.Agent], error) + }{ + { + name: "when requesting agent details returns no error", + hostname: "server1", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"hostname":"server1","status":"Ready"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Agent], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("server1", resp.Data.Hostname) + suite.Equal("Ready", resp.Data.Status) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + hostname: "unknown-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"agent not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Agent], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + suite.Equal("agent not found", target.Message) + }, + }, + { + name: "when client HTTP error returns wrapped error", + hostname: "unknown-host", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Agent], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get agent") + }, + }, + { + name: "when response JSON200 is nil returns UnexpectedStatusError", + hostname: "unknown-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Agent], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + url = server.URL + } + + sut := osapi.New( + url, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Agent.Get(suite.ctx, tc.hostname) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *AgentPublicTestSuite) TestDrain() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + hostname string + validateFunc func(*osapi.Response[osapi.MessageResponse], error) + }{ + { + name: "when draining agent returns success", + hostname: "server1", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"drain initiated for agent server1"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("drain initiated for agent server1", resp.Data.Message) + }, + }, + { + name: "when server returns 409 returns ConflictError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"agent already draining"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ConflictError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusConflict, target.StatusCode) + suite.Equal("agent already draining", target.Message) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"agent not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when client HTTP error returns wrapped error", + hostname: "test-host", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "drain agent") + }, + }, + { + name: "when response JSON200 is nil returns UnexpectedStatusError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + url = server.URL + } + + sut := osapi.New( + url, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Agent.Drain(suite.ctx, tc.hostname) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *AgentPublicTestSuite) TestUndrain() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + hostname string + validateFunc func(*osapi.Response[osapi.MessageResponse], error) + }{ + { + name: "when undraining agent returns success", + hostname: "server1", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message":"undrain initiated for agent server1"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("undrain initiated for agent server1", resp.Data.Message) + }, + }, + { + name: "when server returns 409 returns ConflictError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"agent not in draining state"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ConflictError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusConflict, target.StatusCode) + suite.Equal("agent not in draining state", target.Message) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"agent not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when client HTTP error returns wrapped error", + hostname: "test-host", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "undrain agent") + }, + }, + { + name: "when response JSON200 is nil returns UnexpectedStatusError", + hostname: "test-host", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + url = server.URL + } + + sut := osapi.New( + url, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Agent.Undrain(suite.ctx, tc.hostname) + tc.validateFunc(resp, err) + }) + } +} + +func TestAgentPublicTestSuite(t *testing.T) { + suite.Run(t, new(AgentPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/agent_types.go b/pkg/sdk/osapi/agent_types.go new file mode 100644 index 00000000..d7f9b052 --- /dev/null +++ b/pkg/sdk/osapi/agent_types.go @@ -0,0 +1,290 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "time" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// 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 +} + +// Condition represents a node condition evaluated agent-side. +type Condition struct { + Type string + Status bool + Reason string + LastTransitionTime time.Time +} + +// AgentList is a collection of agents. +type AgentList struct { + Agents []Agent + Total int +} + +// NetworkInterface represents a network interface on an agent. +type NetworkInterface struct { + Name string + Family string + IPv4 string + IPv6 string + MAC string +} + +// Route represents a network routing table entry. +type Route struct { + Destination string + Gateway string + Interface string + Mask string + Flags string + Metric int +} + +// LoadAverage represents system load averages. +type LoadAverage struct { + OneMin float32 + FiveMin float32 + FifteenMin float32 +} + +// Memory represents memory usage information. +type Memory struct { + Total int + Used int + Free int +} + +// OSInfo represents operating system information. +type OSInfo struct { + Distribution string + Version string +} + +// agentFromGen converts a gen.AgentInfo to an Agent. +func agentFromGen( + g *gen.AgentInfo, +) Agent { + a := Agent{ + Hostname: g.Hostname, + Status: string(g.Status), + } + + if g.Labels != nil { + a.Labels = *g.Labels + } + + if g.Architecture != nil { + a.Architecture = *g.Architecture + } + + if g.CpuCount != nil { + a.CPUCount = *g.CpuCount + } + + if g.Fqdn != nil { + a.Fqdn = *g.Fqdn + } + + if g.KernelVersion != nil { + a.KernelVersion = *g.KernelVersion + } + + if g.PackageMgr != nil { + a.PackageMgr = *g.PackageMgr + } + + if g.ServiceMgr != nil { + a.ServiceMgr = *g.ServiceMgr + } + + a.LoadAverage = loadAverageFromGen(g.LoadAverage) + a.Memory = memoryFromGen(g.Memory) + a.OSInfo = osInfoFromGen(g.OsInfo) + + if g.PrimaryInterface != nil { + a.PrimaryInterface = *g.PrimaryInterface + } + + if g.Routes != nil { + routes := make([]Route, 0, len(*g.Routes)) + for _, r := range *g.Routes { + route := Route{ + Destination: r.Destination, + Gateway: r.Gateway, + Interface: r.Interface, + } + + if r.Mask != nil { + route.Mask = *r.Mask + } + + if r.Flags != nil { + route.Flags = *r.Flags + } + + if r.Metric != nil { + route.Metric = *r.Metric + } + + routes = append(routes, route) + } + + a.Routes = routes + } + + if g.Interfaces != nil { + ifaces := make([]NetworkInterface, 0, len(*g.Interfaces)) + for _, iface := range *g.Interfaces { + ni := NetworkInterface{ + Name: iface.Name, + } + + if iface.Family != nil { + ni.Family = string(*iface.Family) + } + + if iface.Ipv4 != nil { + ni.IPv4 = *iface.Ipv4 + } + + if iface.Ipv6 != nil { + ni.IPv6 = *iface.Ipv6 + } + + if iface.Mac != nil { + ni.MAC = *iface.Mac + } + + ifaces = append(ifaces, ni) + } + + a.Interfaces = ifaces + } + + if g.Uptime != nil { + a.Uptime = *g.Uptime + } + + if g.StartedAt != nil { + a.StartedAt = *g.StartedAt + } + + if g.RegisteredAt != nil { + a.RegisteredAt = *g.RegisteredAt + } + + if g.Facts != nil { + a.Facts = *g.Facts + } + + if g.State != nil { + a.State = string(*g.State) + } + + if g.Conditions != nil { + conditions := make([]Condition, 0, len(*g.Conditions)) + for _, c := range *g.Conditions { + cond := Condition{ + Type: string(c.Type), + Status: c.Status, + LastTransitionTime: c.LastTransitionTime, + } + + if c.Reason != nil { + cond.Reason = *c.Reason + } + + conditions = append(conditions, cond) + } + + a.Conditions = conditions + } + + if g.Timeline != nil { + timeline := make([]TimelineEvent, 0, len(*g.Timeline)) + for _, t := range *g.Timeline { + te := TimelineEvent{ + Event: t.Event, + Timestamp: t.Timestamp.Format(time.RFC3339), + } + + if t.Hostname != nil { + te.Hostname = *t.Hostname + } + + if t.Message != nil { + te.Message = *t.Message + } + + if t.Error != nil { + te.Error = *t.Error + } + + timeline = append(timeline, te) + } + + a.Timeline = timeline + } + + return a +} + +// agentListFromGen converts a gen.ListAgentsResponse to an AgentList. +func agentListFromGen( + g *gen.ListAgentsResponse, +) AgentList { + agents := make([]Agent, 0, len(g.Agents)) + for i := range g.Agents { + agents = append(agents, agentFromGen(&g.Agents[i])) + } + + return AgentList{ + Agents: agents, + Total: g.Total, + } +} diff --git a/pkg/sdk/osapi/agent_types_test.go b/pkg/sdk/osapi/agent_types_test.go new file mode 100644 index 00000000..e638c11f --- /dev/null +++ b/pkg/sdk/osapi/agent_types_test.go @@ -0,0 +1,325 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + "time" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type AgentTypesTestSuite struct { + suite.Suite +} + +func (suite *AgentTypesTestSuite) TestAgentFromGen() { + now := time.Now().UTC().Truncate(time.Second) + startedAt := now.Add(-1 * time.Hour) + + tests := []struct { + name string + input *gen.AgentInfo + validateFunc func(Agent) + }{ + { + name: "when all fields are populated", + input: func() *gen.AgentInfo { + labels := map[string]string{"group": "web", "env": "prod"} + arch := "amd64" + cpuCount := 8 + fqdn := "web-01.example.com" + kernelVersion := "5.15.0-generic" + packageMgr := "apt" + serviceMgr := "systemd" + primaryIface := "eth0" + routeMask := "255.255.255.0" + routeFlags := "UG" + routeMetric := 100 + uptime := "5d 3h 22m" + family := gen.NetworkInterfaceResponseFamily("inet") + ipv4 := "192.168.1.10" + ipv6 := "fe80::1" + mac := "00:11:22:33:44:55" + facts := map[string]interface{}{"custom_key": "custom_value"} + state := gen.AgentInfoStateReady + reason := "load avg 0.50 < 4.00" + condTime := now.Add(-30 * time.Minute) + hostname := "web-01" + message := "agent started" + errMsg := "connection lost" + + return &gen.AgentInfo{ + Hostname: "web-01", + Status: gen.AgentInfoStatus("Ready"), + Labels: &labels, + Architecture: &arch, + CpuCount: &cpuCount, + Fqdn: &fqdn, + KernelVersion: &kernelVersion, + PackageMgr: &packageMgr, + ServiceMgr: &serviceMgr, + LoadAverage: &gen.LoadAverageResponse{ + N1min: 0.5, + N5min: 1.2, + N15min: 0.8, + }, + Memory: &gen.MemoryResponse{ + Total: 8589934592, + Used: 4294967296, + Free: 4294967296, + }, + OsInfo: &gen.OSInfoResponse{ + Distribution: "Ubuntu", + Version: "22.04", + }, + PrimaryInterface: &primaryIface, + Interfaces: &[]gen.NetworkInterfaceResponse{ + { + Name: "eth0", + Family: &family, + Ipv4: &ipv4, + Ipv6: &ipv6, + Mac: &mac, + }, + }, + Routes: &[]gen.RouteResponse{ + { + Destination: "0.0.0.0", + Gateway: "192.168.1.1", + Interface: "eth0", + Mask: &routeMask, + Flags: &routeFlags, + Metric: &routeMetric, + }, + }, + Uptime: &uptime, + StartedAt: &startedAt, + RegisteredAt: &now, + Facts: &facts, + State: &state, + Conditions: &[]gen.NodeCondition{ + { + Type: gen.HighLoad, + Status: false, + Reason: &reason, + LastTransitionTime: condTime, + }, + }, + Timeline: &[]gen.TimelineEvent{ + { + Timestamp: startedAt, + Event: "AgentStarted", + Hostname: &hostname, + Message: &message, + }, + { + Timestamp: now, + Event: "AgentFailed", + Hostname: &hostname, + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(a Agent) { + suite.Equal("web-01", a.Hostname) + suite.Equal("Ready", a.Status) + suite.Equal("Ready", a.State) + suite.Equal(map[string]string{"group": "web", "env": "prod"}, a.Labels) + suite.Equal("amd64", a.Architecture) + suite.Equal(8, a.CPUCount) + suite.Equal("web-01.example.com", a.Fqdn) + suite.Equal("5.15.0-generic", a.KernelVersion) + suite.Equal("apt", a.PackageMgr) + suite.Equal("systemd", a.ServiceMgr) + + suite.Require().NotNil(a.LoadAverage) + suite.InDelta(0.5, float64(a.LoadAverage.OneMin), 0.001) + suite.InDelta(1.2, float64(a.LoadAverage.FiveMin), 0.001) + suite.InDelta(0.8, float64(a.LoadAverage.FifteenMin), 0.001) + + suite.Require().NotNil(a.Memory) + suite.Equal(8589934592, a.Memory.Total) + suite.Equal(4294967296, a.Memory.Used) + suite.Equal(4294967296, a.Memory.Free) + + suite.Require().NotNil(a.OSInfo) + suite.Equal("Ubuntu", a.OSInfo.Distribution) + suite.Equal("22.04", a.OSInfo.Version) + + suite.Equal("eth0", a.PrimaryInterface) + + suite.Require().Len(a.Interfaces, 1) + suite.Equal("eth0", a.Interfaces[0].Name) + suite.Equal("inet", a.Interfaces[0].Family) + suite.Equal("192.168.1.10", a.Interfaces[0].IPv4) + suite.Equal("fe80::1", a.Interfaces[0].IPv6) + suite.Equal("00:11:22:33:44:55", a.Interfaces[0].MAC) + + suite.Require().Len(a.Routes, 1) + suite.Equal("0.0.0.0", a.Routes[0].Destination) + suite.Equal("192.168.1.1", a.Routes[0].Gateway) + suite.Equal("eth0", a.Routes[0].Interface) + suite.Equal("255.255.255.0", a.Routes[0].Mask) + suite.Equal("UG", a.Routes[0].Flags) + suite.Equal(100, a.Routes[0].Metric) + + suite.Equal("5d 3h 22m", a.Uptime) + suite.Equal(startedAt, a.StartedAt) + suite.Equal(now, a.RegisteredAt) + suite.Equal(map[string]any{"custom_key": "custom_value"}, a.Facts) + + suite.Require().Len(a.Conditions, 1) + suite.Equal("HighLoad", a.Conditions[0].Type) + suite.False(a.Conditions[0].Status) + suite.Equal("load avg 0.50 < 4.00", a.Conditions[0].Reason) + suite.Equal( + now.Add(-30*time.Minute), + a.Conditions[0].LastTransitionTime, + ) + + suite.Require().Len(a.Timeline, 2) + suite.Equal("AgentStarted", a.Timeline[0].Event) + suite.Equal(startedAt.Format(time.RFC3339), a.Timeline[0].Timestamp) + suite.Equal("web-01", a.Timeline[0].Hostname) + suite.Equal("agent started", a.Timeline[0].Message) + suite.Empty(a.Timeline[0].Error) + + suite.Equal("AgentFailed", a.Timeline[1].Event) + suite.Equal(now.Format(time.RFC3339), a.Timeline[1].Timestamp) + suite.Equal("web-01", a.Timeline[1].Hostname) + suite.Empty(a.Timeline[1].Message) + suite.Equal("connection lost", a.Timeline[1].Error) + }, + }, + { + name: "when only required fields are set", + input: &gen.AgentInfo{ + Hostname: "minimal-host", + Status: gen.AgentInfoStatus("Ready"), + }, + validateFunc: func(a Agent) { + suite.Equal("minimal-host", a.Hostname) + suite.Equal("Ready", a.Status) + suite.Empty(a.State) + suite.Nil(a.Labels) + suite.Empty(a.Architecture) + suite.Zero(a.CPUCount) + suite.Empty(a.Fqdn) + suite.Empty(a.KernelVersion) + suite.Empty(a.PackageMgr) + suite.Empty(a.ServiceMgr) + suite.Nil(a.LoadAverage) + suite.Nil(a.Memory) + suite.Nil(a.OSInfo) + suite.Empty(a.PrimaryInterface) + suite.Nil(a.Interfaces) + suite.Nil(a.Routes) + suite.Nil(a.Conditions) + suite.Nil(a.Timeline) + suite.Empty(a.Uptime) + suite.True(a.StartedAt.IsZero()) + suite.True(a.RegisteredAt.IsZero()) + suite.Nil(a.Facts) + }, + }, + { + name: "when interfaces list is empty", + input: func() *gen.AgentInfo { + ifaces := []gen.NetworkInterfaceResponse{} + + return &gen.AgentInfo{ + Hostname: "no-ifaces", + Status: gen.AgentInfoStatus("Ready"), + Interfaces: &ifaces, + } + }(), + validateFunc: func(a Agent) { + suite.Equal("no-ifaces", a.Hostname) + suite.NotNil(a.Interfaces) + suite.Empty(a.Interfaces) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := agentFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *AgentTypesTestSuite) TestAgentListFromGen() { + tests := []struct { + name string + input *gen.ListAgentsResponse + validateFunc func(AgentList) + }{ + { + name: "when list contains agents", + input: &gen.ListAgentsResponse{ + Agents: []gen.AgentInfo{ + { + Hostname: "web-01", + Status: gen.AgentInfoStatus("Ready"), + }, + { + Hostname: "web-02", + Status: gen.AgentInfoStatus("Ready"), + }, + }, + Total: 2, + }, + validateFunc: func(al AgentList) { + suite.Equal(2, al.Total) + suite.Require().Len(al.Agents, 2) + suite.Equal("web-01", al.Agents[0].Hostname) + suite.Equal("web-02", al.Agents[1].Hostname) + }, + }, + { + name: "when list is empty", + input: &gen.ListAgentsResponse{ + Agents: []gen.AgentInfo{}, + Total: 0, + }, + validateFunc: func(al AgentList) { + suite.Equal(0, al.Total) + suite.Empty(al.Agents) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := agentListFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestAgentTypesTestSuite(t *testing.T) { + suite.Run(t, new(AgentTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/audit.go b/pkg/sdk/osapi/audit.go new file mode 100644 index 00000000..b06e3e2d --- /dev/null +++ b/pkg/sdk/osapi/audit.go @@ -0,0 +1,134 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// AuditService provides audit log operations. +type AuditService struct { + client *gen.ClientWithResponses +} + +// List retrieves audit log entries with pagination. +func (s *AuditService) List( + ctx context.Context, + limit int, + offset int, +) (*Response[AuditList], error) { + params := &gen.GetAuditLogsParams{ + Limit: &limit, + Offset: &offset, + } + + resp, err := s.client.GetAuditLogsWithResponse(ctx, params) + if err != nil { + return nil, fmt.Errorf("list audit logs: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(auditListFromGen(resp.JSON200), resp.Body), nil +} + +// Get retrieves a single audit log entry by ID. +func (s *AuditService) Get( + ctx context.Context, + id string, +) (*Response[AuditEntry], error) { + parsedID, err := uuid.Parse(id) + if err != nil { + return nil, err + } + + resp, err := s.client.GetAuditLogByIDWithResponse(ctx, parsedID) + if err != nil { + return nil, fmt.Errorf("get audit log %s: %w", id, err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON404, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(auditEntryFromGen(resp.JSON200.Entry), resp.Body), nil +} + +// Export retrieves all audit log entries for export. +func (s *AuditService) Export( + ctx context.Context, +) (*Response[AuditList], error) { + resp, err := s.client.GetAuditExportWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("export audit logs: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(auditListFromGen(resp.JSON200), resp.Body), nil +} diff --git a/pkg/sdk/osapi/audit_public_test.go b/pkg/sdk/osapi/audit_public_test.go new file mode 100644 index 00000000..1318d9ef --- /dev/null +++ b/pkg/sdk/osapi/audit_public_test.go @@ -0,0 +1,341 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type AuditPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *AuditPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *AuditPublicTestSuite) TestList() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + limit int + offset int + validateFunc func(*osapi.Response[osapi.AuditList], error) + }{ + { + name: "when listing audit entries returns audit list", + limit: 20, + offset: 0, + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"items":[],"total_items":0}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal(0, resp.Data.TotalItems) + suite.Empty(resp.Data.Items) + }, + }, + { + name: "when server returns 401 returns AuthError", + limit: 20, + offset: 0, + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when client HTTP request fails returns error", + limit: 20, + offset: 0, + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "list audit logs:") + }, + }, + { + name: "when response body is nil returns UnexpectedStatusError", + limit: 20, + offset: 0, + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + serverURL := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Audit.List(suite.ctx, tc.limit, tc.offset) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *AuditPublicTestSuite) TestGet() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + id string + validateFunc func(*osapi.Response[osapi.AuditEntry], error) + }{ + { + name: "when valid UUID returns audit entry", + id: "550e8400-e29b-41d4-a716-446655440000", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"entry":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2026-01-01T00:00:00Z","user":"admin","roles":["admin"],"method":"GET","path":"/api/v1/health","response_code":200,"duration_ms":5,"source_ip":"127.0.0.1"}}`, + ), + ) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.ID) + suite.Equal("admin", resp.Data.User) + suite.Equal("GET", resp.Data.Method) + suite.Equal("/api/v1/health", resp.Data.Path) + }, + }, + { + name: "when invalid UUID returns error", + id: "not-a-uuid", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"entry":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2026-01-01T00:00:00Z","user":"admin","roles":["admin"],"method":"GET","path":"/api/v1/health","response_code":200,"duration_ms":5,"source_ip":"127.0.0.1"}}`, + ), + ) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) { + suite.Error(err) + suite.Nil(resp) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + id: "550e8400-e29b-41d4-a716-446655440000", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"audit entry not found"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when client HTTP request fails returns error", + id: "550e8400-e29b-41d4-a716-446655440000", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get audit log") + }, + }, + { + name: "when response body is nil returns UnexpectedStatusError", + id: "550e8400-e29b-41d4-a716-446655440000", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + serverURL := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Audit.Get(suite.ctx, tc.id) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *AuditPublicTestSuite) TestExport() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.AuditList], error) + }{ + { + name: "when exporting audit entries returns audit list", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"items":[],"total_items":0}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal(0, resp.Data.TotalItems) + suite.Empty(resp.Data.Items) + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when client HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "export audit logs:") + }, + }, + { + name: "when response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + serverURL := tc.serverURL + if tc.handler != nil { + server := httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Audit.Export(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func TestAuditPublicTestSuite(t *testing.T) { + suite.Run(t, new(AuditPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/audit_types.go b/pkg/sdk/osapi/audit_types.go new file mode 100644 index 00000000..3a78de99 --- /dev/null +++ b/pkg/sdk/osapi/audit_types.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "time" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// 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 +} + +// AuditList is a paginated list of audit entries. +type AuditList struct { + Items []AuditEntry + TotalItems int +} + +// auditEntryFromGen converts a gen.AuditEntry to an AuditEntry. +func auditEntryFromGen( + g gen.AuditEntry, +) AuditEntry { + a := AuditEntry{ + ID: g.Id.String(), + Timestamp: g.Timestamp, + User: g.User, + Roles: g.Roles, + Method: g.Method, + Path: g.Path, + ResponseCode: g.ResponseCode, + DurationMs: g.DurationMs, + SourceIP: g.SourceIp, + } + + if g.OperationId != nil { + a.OperationID = *g.OperationId + } + + return a +} + +// auditListFromGen converts a gen.ListAuditResponse to an AuditList. +func auditListFromGen( + g *gen.ListAuditResponse, +) AuditList { + items := make([]AuditEntry, 0, len(g.Items)) + for _, entry := range g.Items { + items = append(items, auditEntryFromGen(entry)) + } + + return AuditList{ + Items: items, + TotalItems: g.TotalItems, + } +} diff --git a/pkg/sdk/osapi/audit_types_test.go b/pkg/sdk/osapi/audit_types_test.go new file mode 100644 index 00000000..313b1ad3 --- /dev/null +++ b/pkg/sdk/osapi/audit_types_test.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + "time" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type AuditTypesTestSuite struct { + suite.Suite +} + +func (suite *AuditTypesTestSuite) TestAuditEntryFromGen() { + now := time.Now().UTC().Truncate(time.Second) + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + operationID := "getNodeHostname" + + tests := []struct { + name string + input gen.AuditEntry + validateFunc func(AuditEntry) + }{ + { + name: "when all fields are populated", + input: gen.AuditEntry{ + Id: testUUID, + Timestamp: now, + User: "admin@example.com", + Roles: []string{"admin", "write"}, + Method: "GET", + Path: "/api/v1/node/web-01", + ResponseCode: 200, + DurationMs: 42, + SourceIp: "192.168.1.100", + OperationId: &operationID, + }, + validateFunc: func(a AuditEntry) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID) + suite.Equal(now, a.Timestamp) + suite.Equal("admin@example.com", a.User) + suite.Equal([]string{"admin", "write"}, a.Roles) + suite.Equal("GET", a.Method) + suite.Equal("/api/v1/node/web-01", a.Path) + suite.Equal(200, a.ResponseCode) + suite.Equal(int64(42), a.DurationMs) + suite.Equal("192.168.1.100", a.SourceIP) + suite.Equal("getNodeHostname", a.OperationID) + }, + }, + { + name: "when OperationId is nil", + input: gen.AuditEntry{ + Id: testUUID, + Timestamp: now, + User: "user@example.com", + Roles: []string{"read"}, + Method: "POST", + Path: "/api/v1/jobs", + ResponseCode: 201, + DurationMs: 15, + SourceIp: "10.0.0.1", + OperationId: nil, + }, + validateFunc: func(a AuditEntry) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", a.ID) + suite.Equal(now, a.Timestamp) + suite.Equal("user@example.com", a.User) + suite.Equal([]string{"read"}, a.Roles) + suite.Equal("POST", a.Method) + suite.Equal("/api/v1/jobs", a.Path) + suite.Equal(201, a.ResponseCode) + suite.Equal(int64(15), a.DurationMs) + suite.Equal("10.0.0.1", a.SourceIP) + suite.Empty(a.OperationID) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := auditEntryFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *AuditTypesTestSuite) TestAuditListFromGen() { + now := time.Now().UTC().Truncate(time.Second) + testUUID1 := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x01, + } + testUUID2 := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x02, + } + + tests := []struct { + name string + input *gen.ListAuditResponse + validateFunc func(AuditList) + }{ + { + name: "when list contains items", + input: &gen.ListAuditResponse{ + Items: []gen.AuditEntry{ + { + Id: testUUID1, + Timestamp: now, + User: "admin@example.com", + Roles: []string{"admin"}, + Method: "GET", + Path: "/api/v1/health", + ResponseCode: 200, + DurationMs: 5, + SourceIp: "192.168.1.1", + }, + { + Id: testUUID2, + Timestamp: now, + User: "user@example.com", + Roles: []string{"read"}, + Method: "POST", + Path: "/api/v1/jobs", + ResponseCode: 201, + DurationMs: 30, + SourceIp: "10.0.0.1", + }, + }, + TotalItems: 2, + }, + validateFunc: func(al AuditList) { + suite.Equal(2, al.TotalItems) + suite.Require().Len(al.Items, 2) + suite.Equal("550e8400-e29b-41d4-a716-446655440001", al.Items[0].ID) + suite.Equal("admin@example.com", al.Items[0].User) + suite.Equal("550e8400-e29b-41d4-a716-446655440002", al.Items[1].ID) + suite.Equal("user@example.com", al.Items[1].User) + }, + }, + { + name: "when list is empty", + input: &gen.ListAuditResponse{ + Items: []gen.AuditEntry{}, + TotalItems: 0, + }, + validateFunc: func(al AuditList) { + suite.Equal(0, al.TotalItems) + suite.Empty(al.Items) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := auditListFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestAuditTypesTestSuite(t *testing.T) { + suite.Run(t, new(AuditTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/errors.go b/pkg/sdk/osapi/errors.go new file mode 100644 index 00000000..508e7421 --- /dev/null +++ b/pkg/sdk/osapi/errors.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import "fmt" + +// APIError is the base error type for OSAPI API errors. +type APIError struct { + StatusCode int + Message string +} + +// Error returns a formatted error string. +func (e *APIError) Error() string { + return fmt.Sprintf( + "api error (status %d): %s", + e.StatusCode, + e.Message, + ) +} + +// AuthError represents authentication/authorization errors (401, 403). +type AuthError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *AuthError) Unwrap() error { + return &e.APIError +} + +// NotFoundError represents resource not found errors (404). +type NotFoundError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *NotFoundError) Unwrap() error { + return &e.APIError +} + +// ValidationError represents validation errors (400). +type ValidationError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *ValidationError) Unwrap() error { + return &e.APIError +} + +// ServerError represents internal server errors (500). +type ServerError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *ServerError) Unwrap() error { + return &e.APIError +} + +// ConflictError represents conflict errors (409). +type ConflictError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *ConflictError) Unwrap() error { + return &e.APIError +} + +// UnexpectedStatusError represents unexpected HTTP status codes. +type UnexpectedStatusError struct { + APIError +} + +// Unwrap returns the underlying APIError. +func (e *UnexpectedStatusError) Unwrap() error { + return &e.APIError +} diff --git a/pkg/sdk/osapi/errors_public_test.go b/pkg/sdk/osapi/errors_public_test.go new file mode 100644 index 00000000..e3f8cbc7 --- /dev/null +++ b/pkg/sdk/osapi/errors_public_test.go @@ -0,0 +1,367 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type ErrorsPublicTestSuite struct { + suite.Suite +} + +func (suite *ErrorsPublicTestSuite) TestErrorFormat() { + tests := []struct { + name string + err error + validateFunc func(error) + }{ + { + name: "when APIError formats correctly", + err: &osapi.APIError{ + StatusCode: 500, + Message: "something went wrong", + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 500): something went wrong", + err.Error(), + ) + }, + }, + { + name: "when AuthError formats correctly", + err: &osapi.AuthError{ + APIError: osapi.APIError{ + StatusCode: 401, + Message: "unauthorized", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 401): unauthorized", + err.Error(), + ) + }, + }, + { + name: "when NotFoundError formats correctly", + err: &osapi.NotFoundError{ + APIError: osapi.APIError{ + StatusCode: 404, + Message: "resource not found", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 404): resource not found", + err.Error(), + ) + }, + }, + { + name: "when ValidationError formats correctly", + err: &osapi.ValidationError{ + APIError: osapi.APIError{ + StatusCode: 400, + Message: "invalid input", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 400): invalid input", + err.Error(), + ) + }, + }, + { + name: "when ServerError formats correctly", + err: &osapi.ServerError{ + APIError: osapi.APIError{ + StatusCode: 500, + Message: "internal server error", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 500): internal server error", + err.Error(), + ) + }, + }, + { + name: "when ConflictError formats correctly", + err: &osapi.ConflictError{ + APIError: osapi.APIError{ + StatusCode: 409, + Message: "already draining", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 409): already draining", + err.Error(), + ) + }, + }, + { + name: "when UnexpectedStatusError formats correctly", + err: &osapi.UnexpectedStatusError{ + APIError: osapi.APIError{ + StatusCode: 418, + Message: "unexpected status", + }, + }, + validateFunc: func(err error) { + suite.Equal( + "api error (status 418): unexpected status", + err.Error(), + ) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(tc.err) + }) + } +} + +func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() { + tests := []struct { + name string + err error + validateFunc func(error) + }{ + { + name: "when AuthError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.AuthError{ + APIError: osapi.APIError{ + StatusCode: 403, + Message: "forbidden", + }, + }), + validateFunc: func(err error) { + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(403, target.StatusCode) + suite.Equal("forbidden", target.Message) + }, + }, + { + name: "when NotFoundError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.NotFoundError{ + APIError: osapi.APIError{ + StatusCode: 404, + Message: "not found", + }, + }), + validateFunc: func(err error) { + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(404, target.StatusCode) + suite.Equal("not found", target.Message) + }, + }, + { + name: "when ValidationError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.ValidationError{ + APIError: osapi.APIError{ + StatusCode: 400, + Message: "bad request", + }, + }), + validateFunc: func(err error) { + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(400, target.StatusCode) + suite.Equal("bad request", target.Message) + }, + }, + { + name: "when ServerError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.ServerError{ + APIError: osapi.APIError{ + StatusCode: 500, + Message: "server failure", + }, + }), + validateFunc: func(err error) { + var target *osapi.ServerError + suite.True(errors.As(err, &target)) + suite.Equal(500, target.StatusCode) + suite.Equal("server failure", target.Message) + }, + }, + { + name: "when ConflictError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.ConflictError{ + APIError: osapi.APIError{ + StatusCode: 409, + Message: "already draining", + }, + }), + validateFunc: func(err error) { + var target *osapi.ConflictError + suite.True(errors.As(err, &target)) + suite.Equal(409, target.StatusCode) + suite.Equal("already draining", target.Message) + }, + }, + { + name: "when UnexpectedStatusError is unwrapped via errors.As", + err: fmt.Errorf("wrapped: %w", &osapi.UnexpectedStatusError{ + APIError: osapi.APIError{ + StatusCode: 502, + Message: "bad gateway", + }, + }), + validateFunc: func(err error) { + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(502, target.StatusCode) + suite.Equal("bad gateway", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(tc.err) + }) + } +} + +func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() { + tests := []struct { + name string + err error + validateFunc func(error) + }{ + { + name: "when AuthError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.AuthError{ + APIError: osapi.APIError{ + StatusCode: 401, + Message: "unauthorized", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(401, target.StatusCode) + suite.Equal("unauthorized", target.Message) + }, + }, + { + name: "when NotFoundError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.NotFoundError{ + APIError: osapi.APIError{ + StatusCode: 404, + Message: "not found", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(404, target.StatusCode) + suite.Equal("not found", target.Message) + }, + }, + { + name: "when ValidationError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.ValidationError{ + APIError: osapi.APIError{ + StatusCode: 400, + Message: "invalid", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(400, target.StatusCode) + suite.Equal("invalid", target.Message) + }, + }, + { + name: "when ServerError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.ServerError{ + APIError: osapi.APIError{ + StatusCode: 500, + Message: "internal error", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(500, target.StatusCode) + suite.Equal("internal error", target.Message) + }, + }, + { + name: "when ConflictError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.ConflictError{ + APIError: osapi.APIError{ + StatusCode: 409, + Message: "conflict", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(409, target.StatusCode) + suite.Equal("conflict", target.Message) + }, + }, + { + name: "when UnexpectedStatusError is matchable as APIError", + err: fmt.Errorf("wrapped: %w", &osapi.UnexpectedStatusError{ + APIError: osapi.APIError{ + StatusCode: 418, + Message: "teapot", + }, + }), + validateFunc: func(err error) { + var target *osapi.APIError + suite.True(errors.As(err, &target)) + suite.Equal(418, target.StatusCode) + suite.Equal("teapot", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(tc.err) + }) + } +} + +func TestErrorsPublicTestSuite(t *testing.T) { + suite.Run(t, new(ErrorsPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/file.go b/pkg/sdk/osapi/file.go new file mode 100644 index 00000000..b5121121 --- /dev/null +++ b/pkg/sdk/osapi/file.go @@ -0,0 +1,270 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "mime/multipart" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// UploadOption configures Upload behavior. +type UploadOption func(*uploadOptions) + +type uploadOptions struct { + force bool +} + +// WithForce bypasses both SDK-side pre-check and server-side digest +// check. The file is always uploaded and changed is always true. +func WithForce() UploadOption { + return func(o *uploadOptions) { o.force = true } +} + +// FileService provides file management operations for the Object Store. +type FileService struct { + client *gen.ClientWithResponses +} + +// Upload uploads a file to the Object Store via multipart/form-data. +// By default, it computes SHA-256 locally and compares against the +// stored hash to skip the upload when content is unchanged. Use +// WithForce to bypass this check. +func (s *FileService) Upload( + ctx context.Context, + name string, + contentType string, + file io.Reader, + opts ...UploadOption, +) (*Response[FileUpload], error) { + var options uploadOptions + for _, o := range opts { + o(&options) + } + + // Buffer file content for hashing and multipart construction. + fileData, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + // Compute SHA-256 locally. + hash := sha256.Sum256(fileData) + sha256Hex := fmt.Sprintf("%x", hash) + + // SDK-side pre-check: skip upload if content unchanged. + // Skipped when force is set. + if !options.force { + existing, err := s.Get(ctx, name) + if err == nil && existing.Data.SHA256 == sha256Hex { + return NewResponse(FileUpload{ + Name: name, + SHA256: sha256Hex, + Size: len(fileData), + Changed: false, + ContentType: contentType, + }, nil), nil + } + // On error (404, network, etc.) fall through to upload. + } + + // Build multipart body from buffered content. + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + _ = writer.WriteField("name", name) + _ = writer.WriteField("content_type", contentType) + + part, _ := writer.CreateFormFile("file", name) + _, _ = part.Write(fileData) + _ = writer.Close() + + // Pass force as query param. + params := &gen.PostFileParams{} + if options.force { + params.Force = &options.force + } + + resp, err := s.client.PostFileWithBodyWithResponse( + ctx, + params, + writer.FormDataContentType(), + body, + ) + if err != nil { + return nil, fmt.Errorf("upload file: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON409, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON201 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileUploadFromGen(resp.JSON201), resp.Body), nil +} + +// List retrieves all files stored in the Object Store. +func (s *FileService) List( + ctx context.Context, +) (*Response[FileList], error) { + resp, err := s.client.GetFilesWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("list files: %w", err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON401, + resp.JSON403, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileListFromGen(resp.JSON200), resp.Body), nil +} + +// Get retrieves metadata for a specific file in the Object Store. +func (s *FileService) Get( + ctx context.Context, + name string, +) (*Response[FileMetadata], error) { + resp, err := s.client.GetFileByNameWithResponse(ctx, name) + if err != nil { + return nil, fmt.Errorf("get file %s: %w", name, err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON404, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileMetadataFromGen(resp.JSON200), resp.Body), nil +} + +// Delete removes a file from the Object Store. +func (s *FileService) Delete( + ctx context.Context, + name string, +) (*Response[FileDelete], error) { + resp, err := s.client.DeleteFileByNameWithResponse(ctx, name) + if err != nil { + return nil, fmt.Errorf("delete file %s: %w", name, err) + } + + if err := checkError( + resp.StatusCode(), + resp.JSON400, + resp.JSON401, + resp.JSON403, + resp.JSON404, + resp.JSON500, + ); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileDeleteFromGen(resp.JSON200), resp.Body), nil +} + +// Changed computes the SHA-256 of the provided content and compares +// it against the stored hash in the Object Store. Returns true if +// the content differs or the file does not exist yet. +func (s *FileService) Changed( + ctx context.Context, + name string, + file io.Reader, +) (*Response[FileChanged], error) { + fileData, err := io.ReadAll(file) + if err != nil { + return nil, fmt.Errorf("read file: %w", err) + } + + hash := sha256.Sum256(fileData) + sha256Hex := fmt.Sprintf("%x", hash) + + existing, err := s.Get(ctx, name) + if err != nil { + var notFound *NotFoundError + if errors.As(err, ¬Found) { + return NewResponse(FileChanged{ + Name: name, + Changed: true, + SHA256: sha256Hex, + }, nil), nil + } + + return nil, fmt.Errorf("check file %s: %w", name, err) + } + + changed := existing.Data.SHA256 != sha256Hex + + return NewResponse(FileChanged{ + Name: name, + Changed: changed, + SHA256: sha256Hex, + }, nil), nil +} diff --git a/pkg/sdk/osapi/file_public_test.go b/pkg/sdk/osapi/file_public_test.go new file mode 100644 index 00000000..43b64084 --- /dev/null +++ b/pkg/sdk/osapi/file_public_test.go @@ -0,0 +1,789 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "bytes" + "context" + "crypto/sha256" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type FilePublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *FilePublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *FilePublicTestSuite) TestUpload() { + fileContent := []byte("content") + hash := sha256.Sum256(fileContent) + contentSHA := fmt.Sprintf("%x", hash) + + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + file io.Reader + opts []osapi.UploadOption + validateFunc func(*osapi.Response[osapi.FileUpload], error) + }{ + { + name: "when uploading new file returns result", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"file not found"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"name":"nginx.conf","sha256":"abc123","size":1024,"changed":true,"content_type":"raw"}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("nginx.conf", resp.Data.Name) + suite.Equal("abc123", resp.Data.SHA256) + suite.Equal(1024, resp.Data.Size) + suite.True(resp.Data.Changed) + suite.Equal("raw", resp.Data.ContentType) + }, + }, + { + name: "when pre-check SHA matches skips upload", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, + `{"name":"nginx.conf","sha256":"%s","size":7,"content_type":"raw"}`, + contentSHA, + ) + return + } + // POST should NOT be called — fail if it is. + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"unexpected POST"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("nginx.conf", resp.Data.Name) + suite.Equal(contentSHA, resp.Data.SHA256) + suite.False(resp.Data.Changed) + suite.Nil(resp.RawJSON()) + }, + }, + { + name: "when force skips pre-check and uploads", + opts: []osapi.UploadOption{osapi.WithForce()}, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + // GET should NOT be called — fail if it is. + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"unexpected GET"}`)) + return + } + suite.Contains(r.URL.RawQuery, "force=true") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"name":"nginx.conf","sha256":"abc123","size":7,"changed":true,"content_type":"raw"}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.True(resp.Data.Changed) + }, + }, + { + name: "when server returns 409 returns ConflictError", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"file not found"}`)) + return + } + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"file already exists"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ConflictError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusConflict, target.StatusCode) + }, + }, + { + name: "when server returns 400 returns ValidationError", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"not found"}`)) + return + } + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"name is required"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + }, + }, + { + name: "when server returns 201 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusNotFound) + return + } + w.WriteHeader(http.StatusCreated) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusCreated, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + { + name: "when file reader returns error", + file: &errReader{err: errors.New("read failed")}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "read file") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + file := tc.file + if file == nil { + file = bytes.NewReader(fileContent) + } + + resp, err := sut.File.Upload( + suite.ctx, + "nginx.conf", + "raw", + file, + tc.opts..., + ) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *FilePublicTestSuite) TestList() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.FileList], error) + }{ + { + name: "when listing files returns results", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"files":[{"name":"file1.txt","sha256":"aaa","size":100,"content_type":"raw"}],"total":1}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileList], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Files, 1) + suite.Equal(1, resp.Data.Total) + suite.Equal("file1.txt", resp.Data.Files[0].Name) + suite.Equal("raw", resp.Data.Files[0].ContentType) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.FileList], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "list files") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.FileList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.File.List(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *FilePublicTestSuite) TestGet() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + fileName string + validateFunc func(*osapi.Response[osapi.FileMetadata], error) + }{ + { + name: "when getting file returns metadata", + fileName: "nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"name":"nginx.conf","sha256":"def456","size":512,"content_type":"raw"}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("nginx.conf", resp.Data.Name) + suite.Equal("def456", resp.Data.SHA256) + suite.Equal(512, resp.Data.Size) + suite.Equal("raw", resp.Data.ContentType) + }, + }, + { + name: "when server returns 400 returns ValidationError", + fileName: "nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid file name"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + fileName: "missing.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"file not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + fileName: "nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + fileName: "nginx.conf", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get file nginx.conf") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + fileName: "nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.File.Get(suite.ctx, tc.fileName) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *FilePublicTestSuite) TestDelete() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + fileName string + validateFunc func(*osapi.Response[osapi.FileDelete], error) + }{ + { + name: "when deleting file returns result", + fileName: "old.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte(`{"name":"old.conf","deleted":true}`), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("old.conf", resp.Data.Name) + suite.True(resp.Data.Deleted) + }, + }, + { + name: "when server returns 400 returns ValidationError", + fileName: "old.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"invalid file name"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 404 returns NotFoundError", + fileName: "missing.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"file not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + fileName: "old.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + fileName: "old.conf", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "delete file old.conf") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + fileName: "old.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.File.Delete(suite.ctx, tc.fileName) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *FilePublicTestSuite) TestChanged() { + fileContent := []byte("content") + hash := sha256.Sum256(fileContent) + contentSHA := fmt.Sprintf("%x", hash) + + differentContent := []byte("different") + diffHash := sha256.Sum256(differentContent) + diffSHA := fmt.Sprintf("%x", diffHash) + + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + file io.Reader + validateFunc func(*osapi.Response[osapi.FileChanged], error) + }{ + { + name: "when file does not exist returns changed true", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"file not found"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.True(resp.Data.Changed) + suite.Equal("nginx.conf", resp.Data.Name) + suite.Equal(contentSHA, resp.Data.SHA256) + }, + }, + { + name: "when SHA matches returns changed false", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, + `{"name":"nginx.conf","sha256":"%s","size":7,"content_type":"raw"}`, + contentSHA, + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.False(resp.Data.Changed) + suite.Equal(contentSHA, resp.Data.SHA256) + }, + }, + { + name: "when SHA differs returns changed true", + file: bytes.NewReader(differentContent), + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprintf(w, + `{"name":"nginx.conf","sha256":"%s","size":7,"content_type":"raw"}`, + contentSHA, + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.True(resp.Data.Changed) + suite.Equal(diffSHA, resp.Data.SHA256) + }, + }, + { + name: "when server returns 403 returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "check file nginx.conf") + }, + }, + { + name: "when file reader returns error", + file: &errReader{err: errors.New("read failed")}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "read file") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + file := tc.file + if file == nil { + file = bytes.NewReader(fileContent) + } + + resp, err := sut.File.Changed( + suite.ctx, + "nginx.conf", + file, + ) + tc.validateFunc(resp, err) + }) + } +} + +type errReader struct { + err error +} + +func (r *errReader) Read( + _ []byte, +) (int, error) { + return 0, r.err +} + +func TestFilePublicTestSuite(t *testing.T) { + suite.Run(t, new(FilePublicTestSuite)) +} diff --git a/pkg/sdk/osapi/file_types.go b/pkg/sdk/osapi/file_types.go new file mode 100644 index 00000000..1ba65683 --- /dev/null +++ b/pkg/sdk/osapi/file_types.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import "github.com/retr0h/osapi/pkg/sdk/osapi/gen" + +// FileUpload represents a successfully uploaded file. +type FileUpload struct { + Name string + SHA256 string + Size int + Changed bool + ContentType string +} + +// FileItem represents file metadata in a list. +type FileItem struct { + Name string + SHA256 string + Size int + ContentType string +} + +// FileList is a collection of files with total count. +type FileList struct { + Files []FileItem + Total int +} + +// FileMetadata represents metadata for a single file. +type FileMetadata struct { + Name string + SHA256 string + Size int + ContentType string +} + +// FileDelete represents the result of a file deletion. +type FileDelete struct { + Name string + Deleted bool +} + +// FileChanged represents the result of a change detection check. +type FileChanged struct { + Name string + Changed bool + SHA256 string +} + +// FileDeployResult represents the result of a file deploy operation. +type FileDeployResult struct { + JobID string + Hostname string + Changed bool +} + +// FileStatusResult represents the result of a file status check. +type FileStatusResult struct { + JobID string + Hostname string + Path string + Status string + SHA256 string +} + +// fileUploadFromGen converts a gen.FileUploadResponse to a FileUpload. +func fileUploadFromGen( + g *gen.FileUploadResponse, +) FileUpload { + return FileUpload{ + Name: g.Name, + SHA256: g.Sha256, + Size: g.Size, + Changed: g.Changed, + ContentType: g.ContentType, + } +} + +// fileListFromGen converts a gen.FileListResponse to a FileList. +func fileListFromGen( + g *gen.FileListResponse, +) FileList { + files := make([]FileItem, 0, len(g.Files)) + for _, f := range g.Files { + files = append(files, FileItem{ + Name: f.Name, + SHA256: f.Sha256, + Size: f.Size, + ContentType: f.ContentType, + }) + } + + return FileList{ + Files: files, + Total: g.Total, + } +} + +// fileMetadataFromGen converts a gen.FileInfoResponse to a FileMetadata. +func fileMetadataFromGen( + g *gen.FileInfoResponse, +) FileMetadata { + return FileMetadata{ + Name: g.Name, + SHA256: g.Sha256, + Size: g.Size, + ContentType: g.ContentType, + } +} + +// fileDeleteFromGen converts a gen.FileDeleteResponse to a FileDelete. +func fileDeleteFromGen( + g *gen.FileDeleteResponse, +) FileDelete { + return FileDelete{ + Name: g.Name, + Deleted: g.Deleted, + } +} + +// fileDeployResultFromGen converts a gen.FileDeployResponse to a FileDeployResult. +func fileDeployResultFromGen( + g *gen.FileDeployResponse, +) FileDeployResult { + return FileDeployResult{ + JobID: g.JobId, + Hostname: g.Hostname, + Changed: g.Changed, + } +} + +// fileStatusResultFromGen converts a gen.FileStatusResponse to a FileStatusResult. +func fileStatusResultFromGen( + g *gen.FileStatusResponse, +) FileStatusResult { + r := FileStatusResult{ + JobID: g.JobId, + Hostname: g.Hostname, + Path: g.Path, + Status: g.Status, + } + + if g.Sha256 != nil { + r.SHA256 = *g.Sha256 + } + + return r +} diff --git a/pkg/sdk/osapi/file_types_test.go b/pkg/sdk/osapi/file_types_test.go new file mode 100644 index 00000000..1e31370b --- /dev/null +++ b/pkg/sdk/osapi/file_types_test.go @@ -0,0 +1,264 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type FileTypesTestSuite struct { + suite.Suite +} + +func (suite *FileTypesTestSuite) TestFileUploadFromGen() { + tests := []struct { + name string + input *gen.FileUploadResponse + validateFunc func(FileUpload) + }{ + { + name: "when all fields populated returns FileUpload", + input: &gen.FileUploadResponse{ + Name: "nginx.conf", + Sha256: "abc123", + Size: 1024, + Changed: true, + ContentType: "raw", + }, + validateFunc: func(result FileUpload) { + suite.Equal("nginx.conf", result.Name) + suite.Equal("abc123", result.SHA256) + suite.Equal(1024, result.Size) + suite.True(result.Changed) + suite.Equal("raw", result.ContentType) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileUploadFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *FileTypesTestSuite) TestFileListFromGen() { + tests := []struct { + name string + input *gen.FileListResponse + validateFunc func(FileList) + }{ + { + name: "when files exist returns FileList with items", + input: &gen.FileListResponse{ + Files: []gen.FileInfo{ + {Name: "file1.txt", Sha256: "aaa", Size: 100, ContentType: "raw"}, + {Name: "file2.txt", Sha256: "bbb", Size: 200, ContentType: "template"}, + }, + Total: 2, + }, + validateFunc: func(result FileList) { + suite.Len(result.Files, 2) + suite.Equal(2, result.Total) + suite.Equal("file1.txt", result.Files[0].Name) + suite.Equal("aaa", result.Files[0].SHA256) + suite.Equal(100, result.Files[0].Size) + suite.Equal("raw", result.Files[0].ContentType) + suite.Equal("file2.txt", result.Files[1].Name) + suite.Equal("template", result.Files[1].ContentType) + }, + }, + { + name: "when no files returns empty FileList", + input: &gen.FileListResponse{ + Files: []gen.FileInfo{}, + Total: 0, + }, + validateFunc: func(result FileList) { + suite.Empty(result.Files) + suite.Equal(0, result.Total) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileListFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *FileTypesTestSuite) TestFileMetadataFromGen() { + tests := []struct { + name string + input *gen.FileInfoResponse + validateFunc func(FileMetadata) + }{ + { + name: "when all fields populated returns FileMetadata", + input: &gen.FileInfoResponse{ + Name: "config.yaml", + Sha256: "def456", + Size: 512, + ContentType: "template", + }, + validateFunc: func(result FileMetadata) { + suite.Equal("config.yaml", result.Name) + suite.Equal("def456", result.SHA256) + suite.Equal(512, result.Size) + suite.Equal("template", result.ContentType) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileMetadataFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *FileTypesTestSuite) TestFileDeleteFromGen() { + tests := []struct { + name string + input *gen.FileDeleteResponse + validateFunc func(FileDelete) + }{ + { + name: "when deleted returns FileDelete with true", + input: &gen.FileDeleteResponse{ + Name: "old.conf", + Deleted: true, + }, + validateFunc: func(result FileDelete) { + suite.Equal("old.conf", result.Name) + suite.True(result.Deleted) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileDeleteFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *FileTypesTestSuite) TestFileDeployResultFromGen() { + tests := []struct { + name string + input *gen.FileDeployResponse + validateFunc func(FileDeployResult) + }{ + { + name: "when all fields populated returns FileDeployResult", + input: &gen.FileDeployResponse{ + JobId: "job-123", + Hostname: "web-01", + Changed: true, + }, + validateFunc: func(result FileDeployResult) { + suite.Equal("job-123", result.JobID) + suite.Equal("web-01", result.Hostname) + suite.True(result.Changed) + }, + }, + { + name: "when not changed returns false", + input: &gen.FileDeployResponse{ + JobId: "job-456", + Hostname: "web-02", + Changed: false, + }, + validateFunc: func(result FileDeployResult) { + suite.False(result.Changed) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileDeployResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *FileTypesTestSuite) TestFileStatusResultFromGen() { + sha := "abc123" + + tests := []struct { + name string + input *gen.FileStatusResponse + validateFunc func(FileStatusResult) + }{ + { + name: "when all fields populated returns FileStatusResult", + input: &gen.FileStatusResponse{ + JobId: "job-789", + Hostname: "web-03", + Path: "/etc/nginx/nginx.conf", + Status: "in-sync", + Sha256: &sha, + }, + validateFunc: func(result FileStatusResult) { + suite.Equal("job-789", result.JobID) + suite.Equal("web-03", result.Hostname) + suite.Equal("/etc/nginx/nginx.conf", result.Path) + suite.Equal("in-sync", result.Status) + suite.Equal("abc123", result.SHA256) + }, + }, + { + name: "when sha256 is nil returns empty string", + input: &gen.FileStatusResponse{ + JobId: "job-000", + Hostname: "web-04", + Path: "/etc/missing.conf", + Status: "missing", + Sha256: nil, + }, + validateFunc: func(result FileStatusResult) { + suite.Equal("missing", result.Status) + suite.Empty(result.SHA256) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := fileStatusResultFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestFileTypesTestSuite(t *testing.T) { + suite.Run(t, new(FileTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/gen/cfg.yaml b/pkg/sdk/osapi/gen/cfg.yaml new file mode 100644 index 00000000..692fbabd --- /dev/null +++ b/pkg/sdk/osapi/gen/cfg.yaml @@ -0,0 +1,8 @@ +--- +package: gen +output: client.gen.go +generate: + models: true + client: true +output-options: + skip-prune: true diff --git a/pkg/sdk/osapi/gen/client.gen.go b/pkg/sdk/osapi/gen/client.gen.go new file mode 100644 index 00000000..3a78e45c --- /dev/null +++ b/pkg/sdk/osapi/gen/client.gen.go @@ -0,0 +1,6438 @@ +// Package gen provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package gen + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +const ( + BearerAuthScopes = "BearerAuth.Scopes" +) + +// Defines values for AgentInfoState. +const ( + AgentInfoStateCordoned AgentInfoState = "Cordoned" + AgentInfoStateDraining AgentInfoState = "Draining" + AgentInfoStateReady AgentInfoState = "Ready" +) + +// Defines values for AgentInfoStatus. +const ( + AgentInfoStatusNotReady AgentInfoStatus = "NotReady" + AgentInfoStatusReady AgentInfoStatus = "Ready" +) + +// Defines values for DNSUpdateResultItemStatus. +const ( + DNSUpdateResultItemStatusFailed DNSUpdateResultItemStatus = "failed" + DNSUpdateResultItemStatusOk DNSUpdateResultItemStatus = "ok" +) + +// Defines values for FileDeployRequestContentType. +const ( + FileDeployRequestContentTypeRaw FileDeployRequestContentType = "raw" + FileDeployRequestContentTypeTemplate FileDeployRequestContentType = "template" +) + +// Defines values for NetworkInterfaceResponseFamily. +const ( + Dual NetworkInterfaceResponseFamily = "dual" + Inet NetworkInterfaceResponseFamily = "inet" + Inet6 NetworkInterfaceResponseFamily = "inet6" +) + +// Defines values for NodeConditionType. +const ( + DiskPressure NodeConditionType = "DiskPressure" + HighLoad NodeConditionType = "HighLoad" + MemoryPressure NodeConditionType = "MemoryPressure" +) + +// Defines values for PostFileMultipartBodyContentType. +const ( + PostFileMultipartBodyContentTypeRaw PostFileMultipartBodyContentType = "raw" + PostFileMultipartBodyContentTypeTemplate PostFileMultipartBodyContentType = "template" +) + +// Defines values for GetJobParamsStatus. +const ( + GetJobParamsStatusCompleted GetJobParamsStatus = "completed" + GetJobParamsStatusFailed GetJobParamsStatus = "failed" + GetJobParamsStatusPartialFailure GetJobParamsStatus = "partial_failure" + GetJobParamsStatusProcessing GetJobParamsStatus = "processing" + GetJobParamsStatusSubmitted GetJobParamsStatus = "submitted" +) + +// AgentDetail defines model for AgentDetail. +type AgentDetail struct { + // Hostname Agent hostname. + Hostname string `json:"hostname"` + + // Labels Formatted label string. + Labels *string `json:"labels,omitempty"` + + // Registered Time since last heartbeat registration. + Registered string `json:"registered"` +} + +// AgentInfo defines model for AgentInfo. +type AgentInfo struct { + // Architecture CPU architecture. + Architecture *string `json:"architecture,omitempty"` + + // Conditions Evaluated node conditions. + Conditions *[]NodeCondition `json:"conditions,omitempty"` + + // CpuCount Number of logical CPUs. + CpuCount *int `json:"cpu_count,omitempty"` + + // Facts Extended facts from additional providers. + Facts *map[string]interface{} `json:"facts,omitempty"` + + // Fqdn Fully qualified domain name. + Fqdn *string `json:"fqdn,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + Interfaces *[]NetworkInterfaceResponse `json:"interfaces,omitempty"` + + // KernelVersion OS kernel version. + KernelVersion *string `json:"kernel_version,omitempty"` + + // Labels Key-value labels configured on the agent. + Labels *map[string]string `json:"labels,omitempty"` + + // LoadAverage The system load averages for 1, 5, and 15 minutes. + LoadAverage *LoadAverageResponse `json:"load_average,omitempty"` + + // Memory Memory usage information. + Memory *MemoryResponse `json:"memory,omitempty"` + + // OsInfo Operating system information. + OsInfo *OSInfoResponse `json:"os_info,omitempty"` + + // PackageMgr Package manager. + PackageMgr *string `json:"package_mgr,omitempty"` + + // PrimaryInterface Name of the interface used for the default route. + PrimaryInterface *string `json:"primary_interface,omitempty"` + + // RegisteredAt When the agent last refreshed its heartbeat. + RegisteredAt *time.Time `json:"registered_at,omitempty"` + + // Routes Network routing table entries. + Routes *[]RouteResponse `json:"routes,omitempty"` + + // ServiceMgr Init system. + ServiceMgr *string `json:"service_mgr,omitempty"` + + // StartedAt When the agent process started. + StartedAt *time.Time `json:"started_at,omitempty"` + + // State Agent scheduling state. + State *AgentInfoState `json:"state,omitempty"` + + // Status The current status of the agent. + Status AgentInfoStatus `json:"status"` + + // Timeline Agent state transition history. + Timeline *[]TimelineEvent `json:"timeline,omitempty"` + + // Uptime The system uptime. + Uptime *string `json:"uptime,omitempty"` +} + +// AgentInfoState Agent scheduling state. +type AgentInfoState string + +// AgentInfoStatus The current status of the agent. +type AgentInfoStatus string + +// AgentStats defines model for AgentStats. +type AgentStats struct { + // Agents Per-agent registration details. + Agents *[]AgentDetail `json:"agents,omitempty"` + + // Ready Number of agents with Ready status. + Ready int `json:"ready"` + + // Total Total number of registered agents. + Total int `json:"total"` +} + +// AuditEntry defines model for AuditEntry. +type AuditEntry struct { + // DurationMs Request duration in milliseconds. + DurationMs int64 `json:"duration_ms"` + + // Id Unique identifier for the audit entry. + Id openapi_types.UUID `json:"id"` + + // Method HTTP method. + Method string `json:"method"` + + // OperationId OpenAPI operation ID. + OperationId *string `json:"operation_id,omitempty"` + + // Path Request URL path. + Path string `json:"path"` + + // ResponseCode HTTP response status code. + ResponseCode int `json:"response_code"` + + // Roles Roles from the JWT token. + Roles []string `json:"roles"` + + // SourceIp Client IP address. + SourceIp string `json:"source_ip"` + + // Timestamp When the request was processed. + Timestamp time.Time `json:"timestamp"` + + // User Authenticated user (JWT subject). + User string `json:"user"` +} + +// AuditEntryResponse defines model for AuditEntryResponse. +type AuditEntryResponse struct { + Entry AuditEntry `json:"entry"` +} + +// CommandExecRequest defines model for CommandExecRequest. +type CommandExecRequest struct { + // Args Command arguments. + Args *[]string `json:"args,omitempty"` + + // Command The executable name or path. + Command string `json:"command" validate:"required,min=1"` + + // Cwd Working directory for the command. + Cwd *string `json:"cwd,omitempty"` + + // Timeout Timeout in seconds (default 30, max 300). + Timeout *int `json:"timeout,omitempty" validate:"omitempty,min=1,max=300"` +} + +// CommandResultCollectionResponse defines model for CommandResultCollectionResponse. +type CommandResultCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []CommandResultItem `json:"results"` +} + +// CommandResultItem defines model for CommandResultItem. +type CommandResultItem struct { + // Changed Whether the command modified system state. + Changed *bool `json:"changed,omitempty"` + + // DurationMs Execution time in milliseconds. + DurationMs *int64 `json:"duration_ms,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // ExitCode Exit code of the command. + ExitCode *int `json:"exit_code,omitempty"` + + // Hostname The hostname of the agent that executed the command. + Hostname string `json:"hostname"` + + // Stderr Standard error output of the command. + Stderr *string `json:"stderr,omitempty"` + + // Stdout Standard output of the command. + Stdout *string `json:"stdout,omitempty"` +} + +// CommandShellRequest defines model for CommandShellRequest. +type CommandShellRequest struct { + // Command The full shell command string. + Command string `json:"command" validate:"required,min=1"` + + // Cwd Working directory for the command. + Cwd *string `json:"cwd,omitempty"` + + // Timeout Timeout in seconds (default 30, max 300). + Timeout *int `json:"timeout,omitempty" validate:"omitempty,min=1,max=300"` +} + +// ComponentHealth defines model for ComponentHealth. +type ComponentHealth struct { + // Error Error message when component is unhealthy. + Error *string `json:"error,omitempty"` + + // Status Component health status. + Status string `json:"status"` +} + +// ConsumerDetail defines model for ConsumerDetail. +type ConsumerDetail struct { + // AckPending Messages delivered but not yet acknowledged. + AckPending int `json:"ack_pending"` + + // Name Consumer name. + Name string `json:"name"` + + // Pending Messages not yet delivered. + Pending int `json:"pending"` + + // Redelivered Messages redelivered and not yet acknowledged. + Redelivered int `json:"redelivered"` +} + +// ConsumerStats defines model for ConsumerStats. +type ConsumerStats struct { + // Consumers Per-consumer details. + Consumers *[]ConsumerDetail `json:"consumers,omitempty"` + + // Total Total number of JetStream consumers. + Total int `json:"total"` +} + +// CreateJobRequest defines model for CreateJobRequest. +type CreateJobRequest struct { + // Operation The operation to perform, as a JSON object. + Operation map[string]interface{} `json:"operation" validate:"required"` + + // TargetHostname The target hostname for routing (_any, _all, or specific hostname). + TargetHostname string `json:"target_hostname" validate:"required,min=1,valid_target"` +} + +// CreateJobResponse defines model for CreateJobResponse. +type CreateJobResponse struct { + // JobId Unique identifier for the created job. + JobId openapi_types.UUID `json:"job_id"` + + // Revision The KV revision number. + Revision *int64 `json:"revision,omitempty"` + + // Status Initial status of the job. + Status string `json:"status"` + + // Timestamp Creation timestamp. + Timestamp *string `json:"timestamp,omitempty"` +} + +// DNSConfigCollectionResponse defines model for DNSConfigCollectionResponse. +type DNSConfigCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []DNSConfigResponse `json:"results"` +} + +// DNSConfigResponse defines model for DNSConfigResponse. +type DNSConfigResponse struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent that served this config. + Hostname string `json:"hostname"` + + // SearchDomains List of search domains. + SearchDomains *[]string `json:"search_domains,omitempty"` + + // Servers List of configured DNS servers. + Servers *[]string `json:"servers,omitempty"` +} + +// DNSConfigUpdateRequest defines model for DNSConfigUpdateRequest. +type DNSConfigUpdateRequest struct { + // InterfaceName The name of the network interface to apply DNS configuration to. Accepts alphanumeric names or @fact. references. + InterfaceName string `json:"interface_name" validate:"required,alphanum_or_fact"` + + // SearchDomains New list of search domains to configure. + SearchDomains *[]string `json:"search_domains,omitempty" validate:"required_without=Servers,omitempty,dive,hostname,min=1"` + + // Servers New list of DNS servers to configure. + Servers *[]string `json:"servers,omitempty" validate:"required_without=SearchDomains,omitempty,dive,ip,min=1"` +} + +// DNSUpdateCollectionResponse defines model for DNSUpdateCollectionResponse. +type DNSUpdateCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []DNSUpdateResultItem `json:"results"` +} + +// DNSUpdateResultItem defines model for DNSUpdateResultItem. +type DNSUpdateResultItem struct { + // Changed Whether the DNS configuration was actually modified. + Changed *bool `json:"changed,omitempty"` + Error *string `json:"error,omitempty"` + Hostname string `json:"hostname"` + Status DNSUpdateResultItemStatus `json:"status"` +} + +// DNSUpdateResultItemStatus defines model for DNSUpdateResultItem.Status. +type DNSUpdateResultItemStatus string + +// DiskCollectionResponse defines model for DiskCollectionResponse. +type DiskCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []DiskResultItem `json:"results"` +} + +// DiskResponse Local disk usage information. +type DiskResponse struct { + // Free Free disk space in bytes. + Free int `json:"free"` + + // Name Disk identifier, e.g., "/dev/sda1". + Name string `json:"name"` + + // Total Total disk space in bytes. + Total int `json:"total"` + + // Used Used disk space in bytes. + Used int `json:"used"` +} + +// DiskResultItem defines model for DiskResultItem. +type DiskResultItem struct { + // Disks List of local disk usage information. + Disks *DisksResponse `json:"disks,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` +} + +// DisksResponse List of local disk usage information. +type DisksResponse = []DiskResponse + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + // Code The error code. + Code *int `json:"code,omitempty"` + + // Details Additional details about the error. + Details *string `json:"details,omitempty"` + + // Error A description of the error that occurred. + Error *string `json:"error,omitempty"` +} + +// FileDeleteResponse defines model for FileDeleteResponse. +type FileDeleteResponse struct { + // Deleted Whether the file was deleted. + Deleted bool `json:"deleted"` + + // Name The name of the deleted file. + Name string `json:"name"` +} + +// FileDeployRequest defines model for FileDeployRequest. +type FileDeployRequest struct { + // ContentType Content type — "raw" or "template". + ContentType FileDeployRequestContentType `json:"content_type" validate:"required,oneof=raw template"` + + // Group File owner group. + Group *string `json:"group,omitempty"` + + // Mode File permission mode (e.g., "0644"). + Mode *string `json:"mode,omitempty"` + + // ObjectName Name of the file in the Object Store. + ObjectName string `json:"object_name" validate:"required,min=1,max=255"` + + // Owner File owner user. + Owner *string `json:"owner,omitempty"` + + // Path Destination path on the target filesystem. + Path string `json:"path" validate:"required,min=1"` + + // Vars Template variables when content_type is "template". + Vars *map[string]interface{} `json:"vars,omitempty"` +} + +// FileDeployRequestContentType Content type — "raw" or "template". +type FileDeployRequestContentType string + +// FileDeployResponse defines model for FileDeployResponse. +type FileDeployResponse struct { + // Changed Whether the file was actually written. + Changed bool `json:"changed"` + + // Hostname The agent that processed the job. + Hostname string `json:"hostname"` + + // JobId The ID of the created job. + JobId string `json:"job_id"` +} + +// FileInfo defines model for FileInfo. +type FileInfo struct { + // ContentType How the file should be treated during deploy (raw or template). + ContentType string `json:"content_type"` + + // Name The name of the file. + Name string `json:"name"` + + // Sha256 SHA-256 hash of the file content. + Sha256 string `json:"sha256"` + + // Size File size in bytes. + Size int `json:"size"` +} + +// FileInfoResponse defines model for FileInfoResponse. +type FileInfoResponse struct { + // ContentType How the file should be treated during deploy (raw or template). + ContentType string `json:"content_type"` + + // Name The name of the file. + Name string `json:"name"` + + // Sha256 SHA-256 hash of the file content. + Sha256 string `json:"sha256"` + + // Size File size in bytes. + Size int `json:"size"` +} + +// FileListResponse defines model for FileListResponse. +type FileListResponse struct { + // Files List of stored files. + Files []FileInfo `json:"files"` + + // Total Total number of files. + Total int `json:"total"` +} + +// FileStatusRequest defines model for FileStatusRequest. +type FileStatusRequest struct { + // Path Filesystem path to check. + Path string `json:"path" validate:"required,min=1"` +} + +// FileStatusResponse defines model for FileStatusResponse. +type FileStatusResponse struct { + // Hostname The agent that processed the job. + Hostname string `json:"hostname"` + + // JobId The ID of the created job. + JobId string `json:"job_id"` + + // Path The filesystem path. + Path string `json:"path"` + + // Sha256 Current SHA-256 of the file on disk. + Sha256 *string `json:"sha256,omitempty"` + + // Status File state — "in-sync", "drifted", or "missing". + Status string `json:"status"` +} + +// FileUploadResponse defines model for FileUploadResponse. +type FileUploadResponse struct { + // Changed Whether the file content changed. False when the Object Store already held an object with the same SHA-256 digest. + Changed bool `json:"changed"` + + // ContentType How the file should be treated during deploy (raw or template). + ContentType string `json:"content_type"` + + // Name The name of the uploaded file. + Name string `json:"name"` + + // Sha256 SHA-256 hash of the file content. + Sha256 string `json:"sha256"` + + // Size File size in bytes. + Size int `json:"size"` +} + +// HealthResponse defines model for HealthResponse. +type HealthResponse struct { + // Status Health status. + Status string `json:"status"` +} + +// HostnameCollectionResponse defines model for HostnameCollectionResponse. +type HostnameCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []HostnameResponse `json:"results"` +} + +// HostnameResponse The hostname of the system. +type HostnameResponse struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The system's hostname. + Hostname string `json:"hostname"` + + // Labels Key-value labels configured on the agent. + Labels *map[string]string `json:"labels,omitempty"` +} + +// JobDetailResponse defines model for JobDetailResponse. +type JobDetailResponse struct { + // AgentStates Per-agent processing state for broadcast jobs. + AgentStates *map[string]struct { + Duration *string `json:"duration,omitempty"` + Error *string `json:"error,omitempty"` + Status *string `json:"status,omitempty"` + } `json:"agent_states,omitempty"` + + // Created Creation timestamp. + Created *string `json:"created,omitempty"` + + // Error Error message if failed. + Error *string `json:"error,omitempty"` + + // Hostname Agent hostname that processed the job. + Hostname *string `json:"hostname,omitempty"` + + // Id Unique identifier of the job. + Id *openapi_types.UUID `json:"id,omitempty"` + + // Operation The operation data. + Operation *map[string]interface{} `json:"operation,omitempty"` + + // Responses Per-agent response data for broadcast jobs. + Responses *map[string]struct { + // Data Agent result data. + Data interface{} `json:"data,omitempty"` + Error *string `json:"error,omitempty"` + Hostname *string `json:"hostname,omitempty"` + Status *string `json:"status,omitempty"` + } `json:"responses,omitempty"` + + // Result The result data if completed. + Result interface{} `json:"result,omitempty"` + + // Status Current status of the job. + Status *string `json:"status,omitempty"` + + // Timeline Chronological sequence of job lifecycle events. + Timeline *[]struct { + // Error Error details if applicable. + Error *string `json:"error,omitempty"` + + // Event Event type (submitted, acknowledged, started, completed, failed, retried). + Event *string `json:"event,omitempty"` + + // Hostname Agent or source that generated the event. + Hostname *string `json:"hostname,omitempty"` + + // Message Human-readable description of the event. + Message *string `json:"message,omitempty"` + + // Timestamp ISO 8601 timestamp of the event. + Timestamp *string `json:"timestamp,omitempty"` + } `json:"timeline,omitempty"` + + // UpdatedAt Last update timestamp. + UpdatedAt *string `json:"updated_at,omitempty"` +} + +// JobStats defines model for JobStats. +type JobStats struct { + // Completed Number of completed jobs. + Completed int `json:"completed"` + + // Dlq Number of jobs in the dead letter queue. + Dlq int `json:"dlq"` + + // Failed Number of failed jobs. + Failed int `json:"failed"` + + // Processing Number of jobs currently processing. + Processing int `json:"processing"` + + // Total Total number of jobs. + Total int `json:"total"` + + // Unprocessed Number of unprocessed jobs. + Unprocessed int `json:"unprocessed"` +} + +// KVBucketInfo defines model for KVBucketInfo. +type KVBucketInfo struct { + // Bytes Total bytes in the bucket. + Bytes int `json:"bytes"` + + // Keys Number of keys in the bucket. + Keys int `json:"keys"` + + // Name KV bucket name. + Name string `json:"name"` +} + +// ListAgentsResponse defines model for ListAgentsResponse. +type ListAgentsResponse struct { + Agents []AgentInfo `json:"agents"` + + // Total Total number of active agents. + Total int `json:"total"` +} + +// ListAuditResponse defines model for ListAuditResponse. +type ListAuditResponse struct { + // Items The audit entries for this page. + Items []AuditEntry `json:"items"` + + // TotalItems Total number of audit entries. + TotalItems int `json:"total_items"` +} + +// ListJobsResponse defines model for ListJobsResponse. +type ListJobsResponse struct { + Items *[]JobDetailResponse `json:"items,omitempty"` + + // StatusCounts Count of all jobs by status (submitted, processing, completed, failed, partial_failure). Derived from key names during the listing pass — no extra reads. + StatusCounts *map[string]int `json:"status_counts,omitempty"` + + // TotalItems Total number of jobs matching the filter. + TotalItems *int `json:"total_items,omitempty"` +} + +// LoadAverageResponse The system load averages for 1, 5, and 15 minutes. +type LoadAverageResponse struct { + // N15min Load average for the last 15 minutes. + N15min float32 `json:"15min"` + + // N1min Load average for the last 1 minute. + N1min float32 `json:"1min"` + + // N5min Load average for the last 5 minutes. + N5min float32 `json:"5min"` +} + +// LoadCollectionResponse defines model for LoadCollectionResponse. +type LoadCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []LoadResultItem `json:"results"` +} + +// LoadResultItem defines model for LoadResultItem. +type LoadResultItem struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // LoadAverage The system load averages for 1, 5, and 15 minutes. + LoadAverage *LoadAverageResponse `json:"load_average,omitempty"` +} + +// MemoryCollectionResponse defines model for MemoryCollectionResponse. +type MemoryCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []MemoryResultItem `json:"results"` +} + +// MemoryResponse Memory usage information. +type MemoryResponse struct { + // Free Free memory in bytes. + Free int `json:"free"` + + // Total Total memory in bytes. + Total int `json:"total"` + + // Used Used memory in bytes. + Used int `json:"used"` +} + +// MemoryResultItem defines model for MemoryResultItem. +type MemoryResultItem struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Memory Memory usage information. + Memory *MemoryResponse `json:"memory,omitempty"` +} + +// NATSInfo defines model for NATSInfo. +type NATSInfo struct { + // Url Connected NATS server URL. + Url string `json:"url"` + + // Version NATS server version. + Version string `json:"version"` +} + +// NetworkInterfaceResponse defines model for NetworkInterfaceResponse. +type NetworkInterfaceResponse struct { + // Family IP address family. + Family *NetworkInterfaceResponseFamily `json:"family,omitempty"` + Ipv4 *string `json:"ipv4,omitempty"` + Ipv6 *string `json:"ipv6,omitempty"` + Mac *string `json:"mac,omitempty"` + Name string `json:"name"` +} + +// NetworkInterfaceResponseFamily IP address family. +type NetworkInterfaceResponseFamily string + +// NodeCondition defines model for NodeCondition. +type NodeCondition struct { + LastTransitionTime time.Time `json:"last_transition_time"` + Reason *string `json:"reason,omitempty"` + Status bool `json:"status"` + Type NodeConditionType `json:"type"` +} + +// NodeConditionType defines model for NodeCondition.Type. +type NodeConditionType string + +// NodeStatusCollectionResponse defines model for NodeStatusCollectionResponse. +type NodeStatusCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []NodeStatusResponse `json:"results"` +} + +// NodeStatusResponse defines model for NodeStatusResponse. +type NodeStatusResponse struct { + // Disks List of local disk usage information. + Disks *DisksResponse `json:"disks,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the system. + Hostname string `json:"hostname"` + + // LoadAverage The system load averages for 1, 5, and 15 minutes. + LoadAverage *LoadAverageResponse `json:"load_average,omitempty"` + + // Memory Memory usage information. + Memory *MemoryResponse `json:"memory,omitempty"` + + // OsInfo Operating system information. + OsInfo *OSInfoResponse `json:"os_info,omitempty"` + + // Uptime The uptime of the system. + Uptime *string `json:"uptime,omitempty"` +} + +// OSInfoCollectionResponse defines model for OSInfoCollectionResponse. +type OSInfoCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []OSInfoResultItem `json:"results"` +} + +// OSInfoResponse Operating system information. +type OSInfoResponse struct { + // Distribution The name of the Linux distribution. + Distribution string `json:"distribution"` + + // Version The version of the Linux distribution. + Version string `json:"version"` +} + +// OSInfoResultItem defines model for OSInfoResultItem. +type OSInfoResultItem struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // OsInfo Operating system information. + OsInfo *OSInfoResponse `json:"os_info,omitempty"` +} + +// ObjectStoreInfo defines model for ObjectStoreInfo. +type ObjectStoreInfo struct { + // Name Object Store bucket name. + Name string `json:"name"` + + // Size Total bytes in the store. + Size int `json:"size"` +} + +// PingCollectionResponse defines model for PingCollectionResponse. +type PingCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []PingResponse `json:"results"` +} + +// PingResponse defines model for PingResponse. +type PingResponse struct { + // AvgRtt Average round-trip time in Go time.Duration format. + AvgRtt *string `json:"avg_rtt,omitempty"` + + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent that executed the ping. + Hostname string `json:"hostname"` + + // MaxRtt Maximum round-trip time in Go time.Duration format. + MaxRtt *string `json:"max_rtt,omitempty"` + + // MinRtt Minimum round-trip time in Go time.Duration format. + MinRtt *string `json:"min_rtt,omitempty"` + + // PacketLoss Percentage of packet loss. + PacketLoss *float64 `json:"packet_loss,omitempty"` + + // PacketsReceived Number of packets received. + PacketsReceived *int `json:"packets_received,omitempty"` + + // PacketsSent Number of packets sent. + PacketsSent *int `json:"packets_sent,omitempty"` +} + +// QueueStatsResponse defines model for QueueStatsResponse. +type QueueStatsResponse struct { + // DlqCount Number of jobs in the dead letter queue. + DlqCount *int `json:"dlq_count,omitempty"` + + // StatusCounts Count of jobs by status. + StatusCounts *map[string]int `json:"status_counts,omitempty"` + + // TotalJobs Total number of jobs in the queue. + TotalJobs *int `json:"total_jobs,omitempty"` +} + +// ReadyResponse defines model for ReadyResponse. +type ReadyResponse struct { + // Error Error message when not ready. + Error *string `json:"error,omitempty"` + + // Status Readiness status. + Status string `json:"status"` +} + +// RetryJobRequest defines model for RetryJobRequest. +type RetryJobRequest struct { + // TargetHostname Override target hostname for the retried job. Defaults to _any if not specified. + TargetHostname *string `json:"target_hostname,omitempty" validate:"omitempty,min=1,valid_target"` +} + +// RouteResponse A network routing table entry. +type RouteResponse struct { + // Destination Destination network address. + Destination string `json:"destination"` + + // Flags Route flags. + Flags *string `json:"flags,omitempty"` + + // Gateway Gateway address. + Gateway string `json:"gateway"` + + // Interface Network interface name. + Interface string `json:"interface"` + + // Mask Network mask in CIDR notation. + Mask *string `json:"mask,omitempty"` + + // Metric Route metric. + Metric *int `json:"metric,omitempty"` +} + +// StatusResponse defines model for StatusResponse. +type StatusResponse struct { + Agents *AgentStats `json:"agents,omitempty"` + + // Components Per-component health status. + Components map[string]ComponentHealth `json:"components"` + Consumers *ConsumerStats `json:"consumers,omitempty"` + Jobs *JobStats `json:"jobs,omitempty"` + + // KvBuckets KV bucket statistics. + KvBuckets *[]KVBucketInfo `json:"kv_buckets,omitempty"` + Nats *NATSInfo `json:"nats,omitempty"` + + // ObjectStores Object Store statistics. + ObjectStores *[]ObjectStoreInfo `json:"object_stores,omitempty"` + + // Status Overall health status. + Status string `json:"status"` + + // Streams JetStream stream statistics. + Streams *[]StreamInfo `json:"streams,omitempty"` + + // Uptime Time since server started. + Uptime string `json:"uptime"` + + // Version Application version. + Version string `json:"version"` +} + +// StreamInfo defines model for StreamInfo. +type StreamInfo struct { + // Bytes Total bytes in the stream. + Bytes int `json:"bytes"` + + // Consumers Number of consumers on the stream. + Consumers int `json:"consumers"` + + // Messages Number of messages in the stream. + Messages int `json:"messages"` + + // Name Stream name. + Name string `json:"name"` +} + +// TimelineEvent defines model for TimelineEvent. +type TimelineEvent struct { + Error *string `json:"error,omitempty"` + Event string `json:"event"` + Hostname *string `json:"hostname,omitempty"` + Message *string `json:"message,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +// UptimeCollectionResponse defines model for UptimeCollectionResponse. +type UptimeCollectionResponse struct { + // JobId The job ID used to process this request. + JobId *openapi_types.UUID `json:"job_id,omitempty"` + Results []UptimeResponse `json:"results"` +} + +// UptimeResponse System uptime information. +type UptimeResponse struct { + // Error Error message if the agent failed. + Error *string `json:"error,omitempty"` + + // Hostname The hostname of the agent. + Hostname string `json:"hostname"` + + // Uptime The uptime of the system. + Uptime *string `json:"uptime,omitempty"` +} + +// FileName defines model for FileName. +type FileName = string + +// Hostname defines model for Hostname. +type Hostname = string + +// GetAuditLogsParams defines parameters for GetAuditLogs. +type GetAuditLogsParams struct { + // Limit Maximum number of entries to return. + Limit *int `form:"limit,omitempty" json:"limit,omitempty" validate:"omitempty,min=1,max=100"` + + // Offset Number of entries to skip. + Offset *int `form:"offset,omitempty" json:"offset,omitempty" validate:"omitempty,min=0"` +} + +// PostFileMultipartBody defines parameters for PostFile. +type PostFileMultipartBody struct { + // ContentType How the file should be treated during deploy. "raw" writes bytes as-is; "template" renders with Go text/template and agent facts. + ContentType *PostFileMultipartBodyContentType `json:"content_type,omitempty"` + + // File The file content. + File openapi_types.File `json:"file"` + + // Name The name of the file in the Object Store. + Name string `json:"name"` +} + +// PostFileParams defines parameters for PostFile. +type PostFileParams struct { + // Force When true, bypass the digest check and always write the file. Returns changed=true regardless of whether the content differs from the existing object. + Force *bool `form:"force,omitempty" json:"force,omitempty" validate:"omitempty"` +} + +// PostFileMultipartBodyContentType defines parameters for PostFile. +type PostFileMultipartBodyContentType string + +// GetJobParams defines parameters for GetJob. +type GetJobParams struct { + // Status Filter jobs by status. + Status *GetJobParamsStatus `form:"status,omitempty" json:"status,omitempty" validate:"omitempty,oneof=submitted processing completed failed partial_failure"` + + // Limit Maximum number of jobs per page (1-100). + Limit *int `form:"limit,omitempty" json:"limit,omitempty" validate:"omitempty,min=1,max=100"` + + // Offset Number of jobs to skip for pagination. + Offset *int `form:"offset,omitempty" json:"offset,omitempty" validate:"omitempty,min=0"` +} + +// GetJobParamsStatus defines parameters for GetJob. +type GetJobParamsStatus string + +// PostNodeNetworkPingJSONBody defines parameters for PostNodeNetworkPing. +type PostNodeNetworkPingJSONBody struct { + // Address The IP address of the server to ping. Supports both IPv4 and IPv6. Also accepts @fact. references that are resolved agent-side. + Address string `json:"address" validate:"required,ip_or_fact"` +} + +// PostFileMultipartRequestBody defines body for PostFile for multipart/form-data ContentType. +type PostFileMultipartRequestBody PostFileMultipartBody + +// PostJobJSONRequestBody defines body for PostJob for application/json ContentType. +type PostJobJSONRequestBody = CreateJobRequest + +// RetryJobByIDJSONRequestBody defines body for RetryJobByID for application/json ContentType. +type RetryJobByIDJSONRequestBody = RetryJobRequest + +// PostNodeCommandExecJSONRequestBody defines body for PostNodeCommandExec for application/json ContentType. +type PostNodeCommandExecJSONRequestBody = CommandExecRequest + +// PostNodeCommandShellJSONRequestBody defines body for PostNodeCommandShell for application/json ContentType. +type PostNodeCommandShellJSONRequestBody = CommandShellRequest + +// PostNodeFileDeployJSONRequestBody defines body for PostNodeFileDeploy for application/json ContentType. +type PostNodeFileDeployJSONRequestBody = FileDeployRequest + +// PostNodeFileStatusJSONRequestBody defines body for PostNodeFileStatus for application/json ContentType. +type PostNodeFileStatusJSONRequestBody = FileStatusRequest + +// PutNodeNetworkDNSJSONRequestBody defines body for PutNodeNetworkDNS for application/json ContentType. +type PutNodeNetworkDNSJSONRequestBody = DNSConfigUpdateRequest + +// PostNodeNetworkPingJSONRequestBody defines body for PostNodeNetworkPing for application/json ContentType. +type PostNodeNetworkPingJSONRequestBody PostNodeNetworkPingJSONBody + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetAgent request + GetAgent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAgentDetails request + GetAgentDetails(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DrainAgent request + DrainAgent(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UndrainAgent request + UndrainAgent(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAuditLogs request + GetAuditLogs(ctx context.Context, params *GetAuditLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAuditExport request + GetAuditExport(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetAuditLogByID request + GetAuditLogByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetFiles request + GetFiles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostFileWithBody request with any body + PostFileWithBody(ctx context.Context, params *PostFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteFileByName request + DeleteFileByName(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetFileByName request + GetFileByName(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetHealth request + GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetHealthReady request + GetHealthReady(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetHealthStatus request + GetHealthStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetJob request + GetJob(ctx context.Context, params *GetJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostJobWithBody request with any body + PostJobWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostJob(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetJobStatus request + GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteJobByID request + DeleteJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetJobByID request + GetJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RetryJobByIDWithBody request with any body + RetryJobByIDWithBody(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RetryJobByID(ctx context.Context, id openapi_types.UUID, body RetryJobByIDJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeStatus request + GetNodeStatus(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeCommandExecWithBody request with any body + PostNodeCommandExecWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeCommandExec(ctx context.Context, hostname Hostname, body PostNodeCommandExecJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeCommandShellWithBody request with any body + PostNodeCommandShellWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeCommandShell(ctx context.Context, hostname Hostname, body PostNodeCommandShellJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeDisk request + GetNodeDisk(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeFileDeployWithBody request with any body + PostNodeFileDeployWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeFileDeploy(ctx context.Context, hostname Hostname, body PostNodeFileDeployJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeFileStatusWithBody request with any body + PostNodeFileStatusWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeFileStatus(ctx context.Context, hostname Hostname, body PostNodeFileStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeHostname request + GetNodeHostname(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeLoad request + GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeMemory request + GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PutNodeNetworkDNSWithBody request with any body + PutNodeNetworkDNSWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PutNodeNetworkDNS(ctx context.Context, hostname Hostname, body PutNodeNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeNetworkDNSByInterface request + GetNodeNetworkDNSByInterface(ctx context.Context, hostname Hostname, interfaceName string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostNodeNetworkPingWithBody request with any body + PostNodeNetworkPingWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostNodeNetworkPing(ctx context.Context, hostname Hostname, body PostNodeNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeOS request + GetNodeOS(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetNodeUptime request + GetNodeUptime(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetVersion request + GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetAgent(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAgentRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAgentDetails(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAgentDetailsRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DrainAgent(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDrainAgentRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UndrainAgent(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUndrainAgentRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAuditLogs(ctx context.Context, params *GetAuditLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAuditLogsRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAuditExport(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAuditExportRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetAuditLogByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetAuditLogByIDRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetFiles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetFilesRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostFileWithBody(ctx context.Context, params *PostFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostFileRequestWithBody(c.Server, params, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteFileByName(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteFileByNameRequest(c.Server, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetFileByName(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetFileByNameRequest(c.Server, name) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetHealth(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetHealthRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetHealthReady(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetHealthReadyRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetHealthStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetHealthStatusRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetJob(ctx context.Context, params *GetJobParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetJobRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostJobWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostJobRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostJob(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostJobRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetJobStatusRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteJobByIDRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetJobByIDRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RetryJobByIDWithBody(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRetryJobByIDRequestWithBody(c.Server, id, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RetryJobByID(ctx context.Context, id openapi_types.UUID, body RetryJobByIDJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRetryJobByIDRequest(c.Server, id, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeStatus(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeStatusRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeCommandExecWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeCommandExecRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeCommandExec(ctx context.Context, hostname Hostname, body PostNodeCommandExecJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeCommandExecRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeCommandShellWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeCommandShellRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeCommandShell(ctx context.Context, hostname Hostname, body PostNodeCommandShellJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeCommandShellRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeDisk(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeDiskRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeFileDeployWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeFileDeployRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeFileDeploy(ctx context.Context, hostname Hostname, body PostNodeFileDeployJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeFileDeployRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeFileStatusWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeFileStatusRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeFileStatus(ctx context.Context, hostname Hostname, body PostNodeFileStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeFileStatusRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeHostname(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeHostnameRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeLoad(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeLoadRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeMemory(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeMemoryRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PutNodeNetworkDNSWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutNodeNetworkDNSRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PutNodeNetworkDNS(ctx context.Context, hostname Hostname, body PutNodeNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutNodeNetworkDNSRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeNetworkDNSByInterface(ctx context.Context, hostname Hostname, interfaceName string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeNetworkDNSByInterfaceRequest(c.Server, hostname, interfaceName) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeNetworkPingWithBody(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeNetworkPingRequestWithBody(c.Server, hostname, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostNodeNetworkPing(ctx context.Context, hostname Hostname, body PostNodeNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNodeNetworkPingRequest(c.Server, hostname, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeOS(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeOSRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetNodeUptime(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNodeUptimeRequest(c.Server, hostname) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetVersionRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetAgentRequest generates requests for GetAgent +func NewGetAgentRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/agent") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAgentDetailsRequest generates requests for GetAgentDetails +func NewGetAgentDetailsRequest(server string, hostname string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/agent/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewDrainAgentRequest generates requests for DrainAgent +func NewDrainAgentRequest(server string, hostname string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/agent/%s/drain", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewUndrainAgentRequest generates requests for UndrainAgent +func NewUndrainAgentRequest(server string, hostname string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/agent/%s/undrain", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAuditLogsRequest generates requests for GetAuditLogs +func NewGetAuditLogsRequest(server string, params *GetAuditLogsParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/audit") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAuditExportRequest generates requests for GetAuditExport +func NewGetAuditExportRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/audit/export") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetAuditLogByIDRequest generates requests for GetAuditLogByID +func NewGetAuditLogByIDRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/audit/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetFilesRequest generates requests for GetFiles +func NewGetFilesRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/file") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostFileRequestWithBody generates requests for PostFile with any type of body +func NewPostFileRequestWithBody(server string, params *PostFileParams, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/file") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Force != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "force", runtime.ParamLocationQuery, *params.Force); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteFileByNameRequest generates requests for DeleteFileByName +func NewDeleteFileByNameRequest(server string, name FileName) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/file/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetFileByNameRequest generates requests for GetFileByName +func NewGetFileByNameRequest(server string, name FileName) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "name", runtime.ParamLocationPath, name) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/file/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetHealthRequest generates requests for GetHealth +func NewGetHealthRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/health") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetHealthReadyRequest generates requests for GetHealthReady +func NewGetHealthReadyRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/health/ready") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetHealthStatusRequest generates requests for GetHealthStatus +func NewGetHealthStatusRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/health/status") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetJobRequest generates requests for GetJob +func NewGetJobRequest(server string, params *GetJobParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Status != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "status", runtime.ParamLocationQuery, *params.Status); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "limit", runtime.ParamLocationQuery, *params.Limit); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "offset", runtime.ParamLocationQuery, *params.Offset); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostJobRequest calls the generic PostJob builder with application/json body +func NewPostJobRequest(server string, body PostJobJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostJobRequestWithBody(server, "application/json", bodyReader) +} + +// NewPostJobRequestWithBody generates requests for PostJob with any type of body +func NewPostJobRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetJobStatusRequest generates requests for GetJobStatus +func NewGetJobStatusRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job/status") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewDeleteJobByIDRequest generates requests for DeleteJobByID +func NewDeleteJobByIDRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetJobByIDRequest generates requests for GetJobByID +func NewGetJobByIDRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRetryJobByIDRequest calls the generic RetryJobByID builder with application/json body +func NewRetryJobByIDRequest(server string, id openapi_types.UUID, body RetryJobByIDJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRetryJobByIDRequestWithBody(server, id, "application/json", bodyReader) +} + +// NewRetryJobByIDRequestWithBody generates requests for RetryJobByID with any type of body +func NewRetryJobByIDRequestWithBody(server string, id openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "id", runtime.ParamLocationPath, id) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job/%s/retry", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetNodeStatusRequest generates requests for GetNodeStatus +func NewGetNodeStatusRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostNodeCommandExecRequest calls the generic PostNodeCommandExec builder with application/json body +func NewPostNodeCommandExecRequest(server string, hostname Hostname, body PostNodeCommandExecJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeCommandExecRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPostNodeCommandExecRequestWithBody generates requests for PostNodeCommandExec with any type of body +func NewPostNodeCommandExecRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/command/exec", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewPostNodeCommandShellRequest calls the generic PostNodeCommandShell builder with application/json body +func NewPostNodeCommandShellRequest(server string, hostname Hostname, body PostNodeCommandShellJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeCommandShellRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPostNodeCommandShellRequestWithBody generates requests for PostNodeCommandShell with any type of body +func NewPostNodeCommandShellRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/command/shell", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetNodeDiskRequest generates requests for GetNodeDisk +func NewGetNodeDiskRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/disk", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostNodeFileDeployRequest calls the generic PostNodeFileDeploy builder with application/json body +func NewPostNodeFileDeployRequest(server string, hostname Hostname, body PostNodeFileDeployJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeFileDeployRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPostNodeFileDeployRequestWithBody generates requests for PostNodeFileDeploy with any type of body +func NewPostNodeFileDeployRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/file/deploy", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewPostNodeFileStatusRequest calls the generic PostNodeFileStatus builder with application/json body +func NewPostNodeFileStatusRequest(server string, hostname Hostname, body PostNodeFileStatusJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeFileStatusRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPostNodeFileStatusRequestWithBody generates requests for PostNodeFileStatus with any type of body +func NewPostNodeFileStatusRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/file/status", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetNodeHostnameRequest generates requests for GetNodeHostname +func NewGetNodeHostnameRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/hostname", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeLoadRequest generates requests for GetNodeLoad +func NewGetNodeLoadRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/load", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeMemoryRequest generates requests for GetNodeMemory +func NewGetNodeMemoryRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/memory", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPutNodeNetworkDNSRequest calls the generic PutNodeNetworkDNS builder with application/json body +func NewPutNodeNetworkDNSRequest(server string, hostname Hostname, body PutNodeNetworkDNSJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPutNodeNetworkDNSRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPutNodeNetworkDNSRequestWithBody generates requests for PutNodeNetworkDNS with any type of body +func NewPutNodeNetworkDNSRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/network/dns", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetNodeNetworkDNSByInterfaceRequest generates requests for GetNodeNetworkDNSByInterface +func NewGetNodeNetworkDNSByInterfaceRequest(server string, hostname Hostname, interfaceName string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "interfaceName", runtime.ParamLocationPath, interfaceName) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/network/dns/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostNodeNetworkPingRequest calls the generic PostNodeNetworkPing builder with application/json body +func NewPostNodeNetworkPingRequest(server string, hostname Hostname, body PostNodeNetworkPingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostNodeNetworkPingRequestWithBody(server, hostname, "application/json", bodyReader) +} + +// NewPostNodeNetworkPingRequestWithBody generates requests for PostNodeNetworkPing with any type of body +func NewPostNodeNetworkPingRequestWithBody(server string, hostname Hostname, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/network/ping", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewGetNodeOSRequest generates requests for GetNodeOS +func NewGetNodeOSRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/os", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetNodeUptimeRequest generates requests for GetNodeUptime +func NewGetNodeUptimeRequest(server string, hostname Hostname) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "hostname", runtime.ParamLocationPath, hostname) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/node/%s/uptime", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetVersionRequest generates requests for GetVersion +func NewGetVersionRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/version") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetAgentWithResponse request + GetAgentWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAgentResponse, error) + + // GetAgentDetailsWithResponse request + GetAgentDetailsWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*GetAgentDetailsResponse, error) + + // DrainAgentWithResponse request + DrainAgentWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*DrainAgentResponse, error) + + // UndrainAgentWithResponse request + UndrainAgentWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*UndrainAgentResponse, error) + + // GetAuditLogsWithResponse request + GetAuditLogsWithResponse(ctx context.Context, params *GetAuditLogsParams, reqEditors ...RequestEditorFn) (*GetAuditLogsResponse, error) + + // GetAuditExportWithResponse request + GetAuditExportWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAuditExportResponse, error) + + // GetAuditLogByIDWithResponse request + GetAuditLogByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetAuditLogByIDResponse, error) + + // GetFilesWithResponse request + GetFilesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetFilesResponse, error) + + // PostFileWithBodyWithResponse request with any body + PostFileWithBodyWithResponse(ctx context.Context, params *PostFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileResponse, error) + + // DeleteFileByNameWithResponse request + DeleteFileByNameWithResponse(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*DeleteFileByNameResponse, error) + + // GetFileByNameWithResponse request + GetFileByNameWithResponse(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*GetFileByNameResponse, error) + + // GetHealthWithResponse request + GetHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthResponse, error) + + // GetHealthReadyWithResponse request + GetHealthReadyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthReadyResponse, error) + + // GetHealthStatusWithResponse request + GetHealthStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthStatusResponse, error) + + // GetJobWithResponse request + GetJobWithResponse(ctx context.Context, params *GetJobParams, reqEditors ...RequestEditorFn) (*GetJobResponse, error) + + // PostJobWithBodyWithResponse request with any body + PostJobWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostJobResponse, error) + + PostJobWithResponse(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*PostJobResponse, error) + + // GetJobStatusWithResponse request + GetJobStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobStatusResponse, error) + + // DeleteJobByIDWithResponse request + DeleteJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error) + + // GetJobByIDWithResponse request + GetJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetJobByIDResponse, error) + + // RetryJobByIDWithBodyWithResponse request with any body + RetryJobByIDWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RetryJobByIDResponse, error) + + RetryJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, body RetryJobByIDJSONRequestBody, reqEditors ...RequestEditorFn) (*RetryJobByIDResponse, error) + + // GetNodeStatusWithResponse request + GetNodeStatusWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeStatusResponse, error) + + // PostNodeCommandExecWithBodyWithResponse request with any body + PostNodeCommandExecWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeCommandExecResponse, error) + + PostNodeCommandExecWithResponse(ctx context.Context, hostname Hostname, body PostNodeCommandExecJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeCommandExecResponse, error) + + // PostNodeCommandShellWithBodyWithResponse request with any body + PostNodeCommandShellWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeCommandShellResponse, error) + + PostNodeCommandShellWithResponse(ctx context.Context, hostname Hostname, body PostNodeCommandShellJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeCommandShellResponse, error) + + // GetNodeDiskWithResponse request + GetNodeDiskWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeDiskResponse, error) + + // PostNodeFileDeployWithBodyWithResponse request with any body + PostNodeFileDeployWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeFileDeployResponse, error) + + PostNodeFileDeployWithResponse(ctx context.Context, hostname Hostname, body PostNodeFileDeployJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeFileDeployResponse, error) + + // PostNodeFileStatusWithBodyWithResponse request with any body + PostNodeFileStatusWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeFileStatusResponse, error) + + PostNodeFileStatusWithResponse(ctx context.Context, hostname Hostname, body PostNodeFileStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeFileStatusResponse, error) + + // GetNodeHostnameWithResponse request + GetNodeHostnameWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeHostnameResponse, error) + + // GetNodeLoadWithResponse request + GetNodeLoadWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLoadResponse, error) + + // GetNodeMemoryWithResponse request + GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error) + + // PutNodeNetworkDNSWithBodyWithResponse request with any body + PutNodeNetworkDNSWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNodeNetworkDNSResponse, error) + + PutNodeNetworkDNSWithResponse(ctx context.Context, hostname Hostname, body PutNodeNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNodeNetworkDNSResponse, error) + + // GetNodeNetworkDNSByInterfaceWithResponse request + GetNodeNetworkDNSByInterfaceWithResponse(ctx context.Context, hostname Hostname, interfaceName string, reqEditors ...RequestEditorFn) (*GetNodeNetworkDNSByInterfaceResponse, error) + + // PostNodeNetworkPingWithBodyWithResponse request with any body + PostNodeNetworkPingWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeNetworkPingResponse, error) + + PostNodeNetworkPingWithResponse(ctx context.Context, hostname Hostname, body PostNodeNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeNetworkPingResponse, error) + + // GetNodeOSWithResponse request + GetNodeOSWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeOSResponse, error) + + // GetNodeUptimeWithResponse request + GetNodeUptimeWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeUptimeResponse, error) + + // GetVersionWithResponse request + GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) +} + +type GetAgentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListAgentsResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetAgentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAgentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetAgentDetailsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AgentInfo + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetAgentDetailsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAgentDetailsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DrainAgentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Message string `json:"message"` + } + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON409 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DrainAgentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DrainAgentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type UndrainAgentResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *struct { + Message string `json:"message"` + } + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON409 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r UndrainAgentResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UndrainAgentResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetAuditLogsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListAuditResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetAuditLogsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAuditLogsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetAuditExportResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListAuditResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetAuditExportResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAuditExportResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetAuditLogByIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *AuditEntryResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetAuditLogByIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetAuditLogByIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetFilesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *FileListResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetFilesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetFilesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostFileResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *FileUploadResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON409 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostFileResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostFileResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteFileByNameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *FileDeleteResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DeleteFileByNameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteFileByNameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetFileByNameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *FileInfoResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetFileByNameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetFileByNameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetHealthResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HealthResponse +} + +// Status returns HTTPResponse.Status +func (r GetHealthResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetHealthResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetHealthReadyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ReadyResponse + JSON503 *ReadyResponse +} + +// Status returns HTTPResponse.Status +func (r GetHealthReadyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetHealthReadyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetHealthStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *StatusResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON503 *StatusResponse +} + +// Status returns HTTPResponse.Status +func (r GetHealthStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetHealthStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetJobResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListJobsResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetJobResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetJobResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostJobResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreateJobResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostJobResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostJobResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetJobStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *QueueStatsResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetJobStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetJobStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteJobByIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DeleteJobByIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteJobByIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetJobByIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *JobDetailResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetJobByIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetJobByIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type RetryJobByIDResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *CreateJobResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r RetryJobByIDResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RetryJobByIDResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *NodeStatusCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeCommandExecResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *CommandResultCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeCommandExecResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeCommandExecResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeCommandShellResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *CommandResultCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeCommandShellResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeCommandShellResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeDiskResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DiskCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeDiskResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeDiskResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeFileDeployResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *FileDeployResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeFileDeployResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeFileDeployResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeFileStatusResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *FileStatusResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeFileStatusResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeFileStatusResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeHostnameResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *HostnameCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeHostnameResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeHostnameResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeLoadResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *LoadCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeLoadResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeLoadResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeMemoryResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *MemoryCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeMemoryResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeMemoryResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PutNodeNetworkDNSResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *DNSUpdateCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PutNodeNetworkDNSResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PutNodeNetworkDNSResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeNetworkDNSByInterfaceResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DNSConfigCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeNetworkDNSByInterfaceResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeNetworkDNSByInterfaceResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostNodeNetworkPingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PingCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostNodeNetworkPingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostNodeNetworkPingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeOSResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *OSInfoCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeOSResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeOSResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetNodeUptimeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *UptimeCollectionResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetNodeUptimeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetNodeUptimeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetVersionResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetVersionResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetVersionResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetAgentWithResponse request returning *GetAgentResponse +func (c *ClientWithResponses) GetAgentWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAgentResponse, error) { + rsp, err := c.GetAgent(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAgentResponse(rsp) +} + +// GetAgentDetailsWithResponse request returning *GetAgentDetailsResponse +func (c *ClientWithResponses) GetAgentDetailsWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*GetAgentDetailsResponse, error) { + rsp, err := c.GetAgentDetails(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAgentDetailsResponse(rsp) +} + +// DrainAgentWithResponse request returning *DrainAgentResponse +func (c *ClientWithResponses) DrainAgentWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*DrainAgentResponse, error) { + rsp, err := c.DrainAgent(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseDrainAgentResponse(rsp) +} + +// UndrainAgentWithResponse request returning *UndrainAgentResponse +func (c *ClientWithResponses) UndrainAgentWithResponse(ctx context.Context, hostname string, reqEditors ...RequestEditorFn) (*UndrainAgentResponse, error) { + rsp, err := c.UndrainAgent(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseUndrainAgentResponse(rsp) +} + +// GetAuditLogsWithResponse request returning *GetAuditLogsResponse +func (c *ClientWithResponses) GetAuditLogsWithResponse(ctx context.Context, params *GetAuditLogsParams, reqEditors ...RequestEditorFn) (*GetAuditLogsResponse, error) { + rsp, err := c.GetAuditLogs(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAuditLogsResponse(rsp) +} + +// GetAuditExportWithResponse request returning *GetAuditExportResponse +func (c *ClientWithResponses) GetAuditExportWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetAuditExportResponse, error) { + rsp, err := c.GetAuditExport(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAuditExportResponse(rsp) +} + +// GetAuditLogByIDWithResponse request returning *GetAuditLogByIDResponse +func (c *ClientWithResponses) GetAuditLogByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetAuditLogByIDResponse, error) { + rsp, err := c.GetAuditLogByID(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetAuditLogByIDResponse(rsp) +} + +// GetFilesWithResponse request returning *GetFilesResponse +func (c *ClientWithResponses) GetFilesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetFilesResponse, error) { + rsp, err := c.GetFiles(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetFilesResponse(rsp) +} + +// PostFileWithBodyWithResponse request with arbitrary body returning *PostFileResponse +func (c *ClientWithResponses) PostFileWithBodyWithResponse(ctx context.Context, params *PostFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostFileResponse, error) { + rsp, err := c.PostFileWithBody(ctx, params, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostFileResponse(rsp) +} + +// DeleteFileByNameWithResponse request returning *DeleteFileByNameResponse +func (c *ClientWithResponses) DeleteFileByNameWithResponse(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*DeleteFileByNameResponse, error) { + rsp, err := c.DeleteFileByName(ctx, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteFileByNameResponse(rsp) +} + +// GetFileByNameWithResponse request returning *GetFileByNameResponse +func (c *ClientWithResponses) GetFileByNameWithResponse(ctx context.Context, name FileName, reqEditors ...RequestEditorFn) (*GetFileByNameResponse, error) { + rsp, err := c.GetFileByName(ctx, name, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetFileByNameResponse(rsp) +} + +// GetHealthWithResponse request returning *GetHealthResponse +func (c *ClientWithResponses) GetHealthWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthResponse, error) { + rsp, err := c.GetHealth(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetHealthResponse(rsp) +} + +// GetHealthReadyWithResponse request returning *GetHealthReadyResponse +func (c *ClientWithResponses) GetHealthReadyWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthReadyResponse, error) { + rsp, err := c.GetHealthReady(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetHealthReadyResponse(rsp) +} + +// GetHealthStatusWithResponse request returning *GetHealthStatusResponse +func (c *ClientWithResponses) GetHealthStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetHealthStatusResponse, error) { + rsp, err := c.GetHealthStatus(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetHealthStatusResponse(rsp) +} + +// GetJobWithResponse request returning *GetJobResponse +func (c *ClientWithResponses) GetJobWithResponse(ctx context.Context, params *GetJobParams, reqEditors ...RequestEditorFn) (*GetJobResponse, error) { + rsp, err := c.GetJob(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetJobResponse(rsp) +} + +// PostJobWithBodyWithResponse request with arbitrary body returning *PostJobResponse +func (c *ClientWithResponses) PostJobWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostJobResponse, error) { + rsp, err := c.PostJobWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostJobResponse(rsp) +} + +func (c *ClientWithResponses) PostJobWithResponse(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*PostJobResponse, error) { + rsp, err := c.PostJob(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostJobResponse(rsp) +} + +// GetJobStatusWithResponse request returning *GetJobStatusResponse +func (c *ClientWithResponses) GetJobStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobStatusResponse, error) { + rsp, err := c.GetJobStatus(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetJobStatusResponse(rsp) +} + +// DeleteJobByIDWithResponse request returning *DeleteJobByIDResponse +func (c *ClientWithResponses) DeleteJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error) { + rsp, err := c.DeleteJobByID(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteJobByIDResponse(rsp) +} + +// GetJobByIDWithResponse request returning *GetJobByIDResponse +func (c *ClientWithResponses) GetJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*GetJobByIDResponse, error) { + rsp, err := c.GetJobByID(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetJobByIDResponse(rsp) +} + +// RetryJobByIDWithBodyWithResponse request with arbitrary body returning *RetryJobByIDResponse +func (c *ClientWithResponses) RetryJobByIDWithBodyWithResponse(ctx context.Context, id openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RetryJobByIDResponse, error) { + rsp, err := c.RetryJobByIDWithBody(ctx, id, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRetryJobByIDResponse(rsp) +} + +func (c *ClientWithResponses) RetryJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, body RetryJobByIDJSONRequestBody, reqEditors ...RequestEditorFn) (*RetryJobByIDResponse, error) { + rsp, err := c.RetryJobByID(ctx, id, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRetryJobByIDResponse(rsp) +} + +// GetNodeStatusWithResponse request returning *GetNodeStatusResponse +func (c *ClientWithResponses) GetNodeStatusWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeStatusResponse, error) { + rsp, err := c.GetNodeStatus(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeStatusResponse(rsp) +} + +// PostNodeCommandExecWithBodyWithResponse request with arbitrary body returning *PostNodeCommandExecResponse +func (c *ClientWithResponses) PostNodeCommandExecWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeCommandExecResponse, error) { + rsp, err := c.PostNodeCommandExecWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeCommandExecResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeCommandExecWithResponse(ctx context.Context, hostname Hostname, body PostNodeCommandExecJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeCommandExecResponse, error) { + rsp, err := c.PostNodeCommandExec(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeCommandExecResponse(rsp) +} + +// PostNodeCommandShellWithBodyWithResponse request with arbitrary body returning *PostNodeCommandShellResponse +func (c *ClientWithResponses) PostNodeCommandShellWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeCommandShellResponse, error) { + rsp, err := c.PostNodeCommandShellWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeCommandShellResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeCommandShellWithResponse(ctx context.Context, hostname Hostname, body PostNodeCommandShellJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeCommandShellResponse, error) { + rsp, err := c.PostNodeCommandShell(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeCommandShellResponse(rsp) +} + +// GetNodeDiskWithResponse request returning *GetNodeDiskResponse +func (c *ClientWithResponses) GetNodeDiskWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeDiskResponse, error) { + rsp, err := c.GetNodeDisk(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeDiskResponse(rsp) +} + +// PostNodeFileDeployWithBodyWithResponse request with arbitrary body returning *PostNodeFileDeployResponse +func (c *ClientWithResponses) PostNodeFileDeployWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeFileDeployResponse, error) { + rsp, err := c.PostNodeFileDeployWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeFileDeployResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeFileDeployWithResponse(ctx context.Context, hostname Hostname, body PostNodeFileDeployJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeFileDeployResponse, error) { + rsp, err := c.PostNodeFileDeploy(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeFileDeployResponse(rsp) +} + +// PostNodeFileStatusWithBodyWithResponse request with arbitrary body returning *PostNodeFileStatusResponse +func (c *ClientWithResponses) PostNodeFileStatusWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeFileStatusResponse, error) { + rsp, err := c.PostNodeFileStatusWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeFileStatusResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeFileStatusWithResponse(ctx context.Context, hostname Hostname, body PostNodeFileStatusJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeFileStatusResponse, error) { + rsp, err := c.PostNodeFileStatus(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeFileStatusResponse(rsp) +} + +// GetNodeHostnameWithResponse request returning *GetNodeHostnameResponse +func (c *ClientWithResponses) GetNodeHostnameWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeHostnameResponse, error) { + rsp, err := c.GetNodeHostname(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeHostnameResponse(rsp) +} + +// GetNodeLoadWithResponse request returning *GetNodeLoadResponse +func (c *ClientWithResponses) GetNodeLoadWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeLoadResponse, error) { + rsp, err := c.GetNodeLoad(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeLoadResponse(rsp) +} + +// GetNodeMemoryWithResponse request returning *GetNodeMemoryResponse +func (c *ClientWithResponses) GetNodeMemoryWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeMemoryResponse, error) { + rsp, err := c.GetNodeMemory(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeMemoryResponse(rsp) +} + +// PutNodeNetworkDNSWithBodyWithResponse request with arbitrary body returning *PutNodeNetworkDNSResponse +func (c *ClientWithResponses) PutNodeNetworkDNSWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNodeNetworkDNSResponse, error) { + rsp, err := c.PutNodeNetworkDNSWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutNodeNetworkDNSResponse(rsp) +} + +func (c *ClientWithResponses) PutNodeNetworkDNSWithResponse(ctx context.Context, hostname Hostname, body PutNodeNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNodeNetworkDNSResponse, error) { + rsp, err := c.PutNodeNetworkDNS(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutNodeNetworkDNSResponse(rsp) +} + +// GetNodeNetworkDNSByInterfaceWithResponse request returning *GetNodeNetworkDNSByInterfaceResponse +func (c *ClientWithResponses) GetNodeNetworkDNSByInterfaceWithResponse(ctx context.Context, hostname Hostname, interfaceName string, reqEditors ...RequestEditorFn) (*GetNodeNetworkDNSByInterfaceResponse, error) { + rsp, err := c.GetNodeNetworkDNSByInterface(ctx, hostname, interfaceName, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeNetworkDNSByInterfaceResponse(rsp) +} + +// PostNodeNetworkPingWithBodyWithResponse request with arbitrary body returning *PostNodeNetworkPingResponse +func (c *ClientWithResponses) PostNodeNetworkPingWithBodyWithResponse(ctx context.Context, hostname Hostname, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNodeNetworkPingResponse, error) { + rsp, err := c.PostNodeNetworkPingWithBody(ctx, hostname, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeNetworkPingResponse(rsp) +} + +func (c *ClientWithResponses) PostNodeNetworkPingWithResponse(ctx context.Context, hostname Hostname, body PostNodeNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNodeNetworkPingResponse, error) { + rsp, err := c.PostNodeNetworkPing(ctx, hostname, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostNodeNetworkPingResponse(rsp) +} + +// GetNodeOSWithResponse request returning *GetNodeOSResponse +func (c *ClientWithResponses) GetNodeOSWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeOSResponse, error) { + rsp, err := c.GetNodeOS(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeOSResponse(rsp) +} + +// GetNodeUptimeWithResponse request returning *GetNodeUptimeResponse +func (c *ClientWithResponses) GetNodeUptimeWithResponse(ctx context.Context, hostname Hostname, reqEditors ...RequestEditorFn) (*GetNodeUptimeResponse, error) { + rsp, err := c.GetNodeUptime(ctx, hostname, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetNodeUptimeResponse(rsp) +} + +// GetVersionWithResponse request returning *GetVersionResponse +func (c *ClientWithResponses) GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) { + rsp, err := c.GetVersion(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetVersionResponse(rsp) +} + +// ParseGetAgentResponse parses an HTTP response from a GetAgentWithResponse call +func ParseGetAgentResponse(rsp *http.Response) (*GetAgentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAgentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListAgentsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetAgentDetailsResponse parses an HTTP response from a GetAgentDetailsWithResponse call +func ParseGetAgentDetailsResponse(rsp *http.Response) (*GetAgentDetailsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAgentDetailsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AgentInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDrainAgentResponse parses an HTTP response from a DrainAgentWithResponse call +func ParseDrainAgentResponse(rsp *http.Response) (*DrainAgentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DrainAgentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Message string `json:"message"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + } + + return response, nil +} + +// ParseUndrainAgentResponse parses an HTTP response from a UndrainAgentWithResponse call +func ParseUndrainAgentResponse(rsp *http.Response) (*UndrainAgentResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UndrainAgentResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest struct { + Message string `json:"message"` + } + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + } + + return response, nil +} + +// ParseGetAuditLogsResponse parses an HTTP response from a GetAuditLogsWithResponse call +func ParseGetAuditLogsResponse(rsp *http.Response) (*GetAuditLogsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAuditLogsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListAuditResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetAuditExportResponse parses an HTTP response from a GetAuditExportWithResponse call +func ParseGetAuditExportResponse(rsp *http.Response) (*GetAuditExportResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAuditExportResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListAuditResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetAuditLogByIDResponse parses an HTTP response from a GetAuditLogByIDWithResponse call +func ParseGetAuditLogByIDResponse(rsp *http.Response) (*GetAuditLogByIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetAuditLogByIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest AuditEntryResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetFilesResponse parses an HTTP response from a GetFilesWithResponse call +func ParseGetFilesResponse(rsp *http.Response) (*GetFilesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetFilesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FileListResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostFileResponse parses an HTTP response from a PostFileWithResponse call +func ParsePostFileResponse(rsp *http.Response) (*PostFileResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostFileResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest FileUploadResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteFileByNameResponse parses an HTTP response from a DeleteFileByNameWithResponse call +func ParseDeleteFileByNameResponse(rsp *http.Response) (*DeleteFileByNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteFileByNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FileDeleteResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetFileByNameResponse parses an HTTP response from a GetFileByNameWithResponse call +func ParseGetFileByNameResponse(rsp *http.Response) (*GetFileByNameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetFileByNameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FileInfoResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetHealthResponse parses an HTTP response from a GetHealthWithResponse call +func ParseGetHealthResponse(rsp *http.Response) (*GetHealthResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetHealthResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HealthResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + +// ParseGetHealthReadyResponse parses an HTTP response from a GetHealthReadyWithResponse call +func ParseGetHealthReadyResponse(rsp *http.Response) (*GetHealthReadyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetHealthReadyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ReadyResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest ReadyResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + +// ParseGetHealthStatusResponse parses an HTTP response from a GetHealthStatusWithResponse call +func ParseGetHealthStatusResponse(rsp *http.Response) (*GetHealthStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetHealthStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest StatusResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest StatusResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest + + } + + return response, nil +} + +// ParseGetJobResponse parses an HTTP response from a GetJobWithResponse call +func ParseGetJobResponse(rsp *http.Response) (*GetJobResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetJobResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListJobsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostJobResponse parses an HTTP response from a PostJobWithResponse call +func ParsePostJobResponse(rsp *http.Response) (*PostJobResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostJobResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreateJobResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetJobStatusResponse parses an HTTP response from a GetJobStatusWithResponse call +func ParseGetJobStatusResponse(rsp *http.Response) (*GetJobStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetJobStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest QueueStatsResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteJobByIDResponse parses an HTTP response from a DeleteJobByIDWithResponse call +func ParseDeleteJobByIDResponse(rsp *http.Response) (*DeleteJobByIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteJobByIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetJobByIDResponse parses an HTTP response from a GetJobByIDWithResponse call +func ParseGetJobByIDResponse(rsp *http.Response) (*GetJobByIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetJobByIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest JobDetailResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseRetryJobByIDResponse parses an HTTP response from a RetryJobByIDWithResponse call +func ParseRetryJobByIDResponse(rsp *http.Response) (*RetryJobByIDResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RetryJobByIDResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest CreateJobResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeStatusResponse parses an HTTP response from a GetNodeStatusWithResponse call +func ParseGetNodeStatusResponse(rsp *http.Response) (*GetNodeStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest NodeStatusCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeCommandExecResponse parses an HTTP response from a PostNodeCommandExecWithResponse call +func ParsePostNodeCommandExecResponse(rsp *http.Response) (*PostNodeCommandExecResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeCommandExecResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest CommandResultCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeCommandShellResponse parses an HTTP response from a PostNodeCommandShellWithResponse call +func ParsePostNodeCommandShellResponse(rsp *http.Response) (*PostNodeCommandShellResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeCommandShellResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest CommandResultCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeDiskResponse parses an HTTP response from a GetNodeDiskWithResponse call +func ParseGetNodeDiskResponse(rsp *http.Response) (*GetNodeDiskResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeDiskResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DiskCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeFileDeployResponse parses an HTTP response from a PostNodeFileDeployWithResponse call +func ParsePostNodeFileDeployResponse(rsp *http.Response) (*PostNodeFileDeployResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeFileDeployResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest FileDeployResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeFileStatusResponse parses an HTTP response from a PostNodeFileStatusWithResponse call +func ParsePostNodeFileStatusResponse(rsp *http.Response) (*PostNodeFileStatusResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeFileStatusResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FileStatusResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeHostnameResponse parses an HTTP response from a GetNodeHostnameWithResponse call +func ParseGetNodeHostnameResponse(rsp *http.Response) (*GetNodeHostnameResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeHostnameResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest HostnameCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeLoadResponse parses an HTTP response from a GetNodeLoadWithResponse call +func ParseGetNodeLoadResponse(rsp *http.Response) (*GetNodeLoadResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeLoadResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest LoadCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeMemoryResponse parses an HTTP response from a GetNodeMemoryWithResponse call +func ParseGetNodeMemoryResponse(rsp *http.Response) (*GetNodeMemoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeMemoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest MemoryCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePutNodeNetworkDNSResponse parses an HTTP response from a PutNodeNetworkDNSWithResponse call +func ParsePutNodeNetworkDNSResponse(rsp *http.Response) (*PutNodeNetworkDNSResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PutNodeNetworkDNSResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest DNSUpdateCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeNetworkDNSByInterfaceResponse parses an HTTP response from a GetNodeNetworkDNSByInterfaceWithResponse call +func ParseGetNodeNetworkDNSByInterfaceResponse(rsp *http.Response) (*GetNodeNetworkDNSByInterfaceResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeNetworkDNSByInterfaceResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DNSConfigCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostNodeNetworkPingResponse parses an HTTP response from a PostNodeNetworkPingWithResponse call +func ParsePostNodeNetworkPingResponse(rsp *http.Response) (*PostNodeNetworkPingResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostNodeNetworkPingResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PingCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeOSResponse parses an HTTP response from a GetNodeOSWithResponse call +func ParseGetNodeOSResponse(rsp *http.Response) (*GetNodeOSResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeOSResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest OSInfoCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetNodeUptimeResponse parses an HTTP response from a GetNodeUptimeWithResponse call +func ParseGetNodeUptimeResponse(rsp *http.Response) (*GetNodeUptimeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetNodeUptimeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest UptimeCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseGetVersionResponse parses an HTTP response from a GetVersionWithResponse call +func ParseGetVersionResponse(rsp *http.Response) (*GetVersionResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetVersionResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + } + + return response, nil +} diff --git a/pkg/sdk/osapi/gen/generate.go b/pkg/sdk/osapi/gen/generate.go new file mode 100644 index 00000000..50ce9d2c --- /dev/null +++ b/pkg/sdk/osapi/gen/generate.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// Package gen contains generated code for the OSAPI REST API client. +package gen + +//go:generate go tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen -config cfg.yaml ../../../../internal/api/gen/api.yaml diff --git a/pkg/sdk/osapi/health.go b/pkg/sdk/osapi/health.go new file mode 100644 index 00000000..723f7544 --- /dev/null +++ b/pkg/sdk/osapi/health.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// HealthService provides health check operations. +type HealthService struct { + client *gen.ClientWithResponses +} + +// Liveness checks if the API server process is alive. +func (s *HealthService) Liveness( + ctx context.Context, +) (*Response[HealthStatus], error) { + resp, err := s.client.GetHealthWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("health liveness: %w", err) + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(healthStatusFromGen(resp.JSON200), resp.Body), nil +} + +// Ready checks if the API server and its dependencies are ready to +// serve traffic. A 503 response is treated as success with the +// ServiceUnavailable flag set. +func (s *HealthService) Ready( + ctx context.Context, +) (*Response[ReadyStatus], error) { + resp, err := s.client.GetHealthReadyWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("health ready: %w", err) + } + + switch resp.StatusCode() { + case 200: + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: 200, + Message: "nil response body", + }} + } + + return NewResponse(readyStatusFromGen(resp.JSON200, false), resp.Body), nil + case 503: + if resp.JSON503 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: 503, + Message: "nil response body", + }} + } + + return NewResponse(readyStatusFromGen(resp.JSON503, true), resp.Body), nil + default: + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "unexpected status", + }} + } +} + +// Status returns detailed system status including component health, +// NATS info, stream stats, and job queue counts. Requires authentication. +// A 503 response is treated as success with the ServiceUnavailable flag set. +func (s *HealthService) Status( + ctx context.Context, +) (*Response[SystemStatus], error) { + resp, err := s.client.GetHealthStatusWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("health status: %w", err) + } + + // Auth errors take precedence. + if resp.StatusCode() == 401 || resp.StatusCode() == 403 { + return nil, checkError(resp.StatusCode(), resp.JSON401, resp.JSON403) + } + + switch resp.StatusCode() { + case 200: + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: 200, + Message: "nil response body", + }} + } + + return NewResponse(systemStatusFromGen(resp.JSON200, false), resp.Body), nil + case 503: + if resp.JSON503 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: 503, + Message: "nil response body", + }} + } + + return NewResponse(systemStatusFromGen(resp.JSON503, true), resp.Body), nil + default: + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "unexpected status", + }} + } +} diff --git a/pkg/sdk/osapi/health_public_test.go b/pkg/sdk/osapi/health_public_test.go new file mode 100644 index 00000000..aff3f455 --- /dev/null +++ b/pkg/sdk/osapi/health_public_test.go @@ -0,0 +1,373 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type HealthPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *HealthPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *HealthPublicTestSuite) runner( + handler http.HandlerFunc, + serverURL string, +) string { + if handler != nil { + server := httptest.NewServer(handler) + suite.T().Cleanup(server.Close) + return server.URL + } + + return serverURL +} + +func (suite *HealthPublicTestSuite) TestLiveness() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.HealthStatus], error) + }{ + { + name: "when checking liveness returns health status", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("ok", resp.Data.Status) + }, + }, + { + name: "when client HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "health liveness") + }, + }, + { + name: "when response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + sut := osapi.New( + suite.runner(tc.handler, tc.serverURL), + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Health.Liveness(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *HealthPublicTestSuite) TestReady() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.ReadyStatus], error) + }{ + { + name: "when checking readiness returns ready status", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ready"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("ready", resp.Data.Status) + suite.False(resp.Data.ServiceUnavailable) + }, + }, + { + name: "when client HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "health ready") + }, + }, + { + name: "when 200 response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + { + name: "when unexpected status returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + }), + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusInternalServerError, target.StatusCode) + suite.Contains(target.Message, "unexpected status") + }, + }, + { + name: "when server returns 503 returns ready status with ServiceUnavailable", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"not_ready","error":"nats down"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("not_ready", resp.Data.Status) + suite.Equal("nats down", resp.Data.Error) + suite.True(resp.Data.ServiceUnavailable) + }, + }, + { + name: "when 503 response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusServiceUnavailable) + }), + validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusServiceUnavailable, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + sut := osapi.New( + suite.runner(tc.handler, tc.serverURL), + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Health.Ready(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *HealthPublicTestSuite) TestStatus() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.SystemStatus], error) + }{ + { + name: "when checking status returns system status", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok","version":"1.0.0","uptime":"1h"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("ok", resp.Data.Status) + suite.Equal("1.0.0", resp.Data.Version) + suite.Equal("1h", resp.Data.Uptime) + suite.False(resp.Data.ServiceUnavailable) + }, + }, + { + name: "when client HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "health status") + }, + }, + { + name: "when 200 response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + { + name: "when unexpected status returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTeapot) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusTeapot, target.StatusCode) + suite.Contains(target.Message, "unexpected status") + }, + }, + { + name: "when server returns 503 returns status with ServiceUnavailable", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"status":"degraded","version":"1.0.0","uptime":"1h"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("degraded", resp.Data.Status) + suite.True(resp.Data.ServiceUnavailable) + }, + }, + { + name: "when 503 response body is nil returns UnexpectedStatusError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusServiceUnavailable) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusServiceUnavailable, target.StatusCode) + suite.Contains(target.Message, "nil response body") + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }), + validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + sut := osapi.New( + suite.runner(tc.handler, tc.serverURL), + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Health.Status(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func TestHealthPublicTestSuite(t *testing.T) { + suite.Run(t, new(HealthPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/health_types.go b/pkg/sdk/osapi/health_types.go new file mode 100644 index 00000000..074e3faa --- /dev/null +++ b/pkg/sdk/osapi/health_types.go @@ -0,0 +1,286 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import "github.com/retr0h/osapi/pkg/sdk/osapi/gen" + +// HealthStatus represents a liveness check response. +type HealthStatus struct { + Status string +} + +// ReadyStatus represents a readiness check response. +type ReadyStatus struct { + Status string + Error string + ServiceUnavailable bool +} + +// 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 +} + +// ComponentHealth represents a component's health. +type ComponentHealth struct { + Status string + Error string +} + +// NATSInfo represents NATS connection info. +type NATSInfo struct { + URL string + Version string +} + +// AgentStats represents agent statistics from the health endpoint. +type AgentStats struct { + Total int + Ready int + Agents []AgentSummary +} + +// AgentSummary represents a summary of an agent from the health endpoint. +type AgentSummary struct { + Hostname string + Labels string + Registered string +} + +// JobStats represents job queue statistics from the health endpoint. +type JobStats struct { + Total int + Completed int + Failed int + Processing int + Unprocessed int + Dlq int +} + +// ConsumerStats represents JetStream consumer statistics. +type ConsumerStats struct { + Total int + Consumers []ConsumerDetail +} + +// ConsumerDetail represents a single consumer's details. +type ConsumerDetail struct { + Name string + Pending int + AckPending int + Redelivered int +} + +// StreamInfo represents a JetStream stream's info. +type StreamInfo struct { + Name string + Messages int + Bytes int + Consumers int +} + +// KVBucketInfo represents a KV bucket's info. +type KVBucketInfo struct { + Name string + Keys int + Bytes int +} + +// ObjectStoreInfo represents an Object Store bucket's info. +type ObjectStoreInfo struct { + Name string + Size int +} + +// healthStatusFromGen converts a gen.HealthResponse to a HealthStatus. +func healthStatusFromGen( + g *gen.HealthResponse, +) HealthStatus { + return HealthStatus{ + Status: g.Status, + } +} + +// readyStatusFromGen converts a gen.ReadyResponse to a ReadyStatus. +func readyStatusFromGen( + g *gen.ReadyResponse, + serviceUnavailable bool, +) ReadyStatus { + r := ReadyStatus{ + Status: g.Status, + ServiceUnavailable: serviceUnavailable, + } + + if g.Error != nil { + r.Error = *g.Error + } + + return r +} + +// systemStatusFromGen converts a gen.StatusResponse to a SystemStatus. +func systemStatusFromGen( + g *gen.StatusResponse, + serviceUnavailable bool, +) SystemStatus { + s := SystemStatus{ + Status: g.Status, + Version: g.Version, + Uptime: g.Uptime, + ServiceUnavailable: serviceUnavailable, + } + + if g.Components != nil { + comps := make(map[string]ComponentHealth, len(g.Components)) + for k, v := range g.Components { + ch := ComponentHealth{ + Status: v.Status, + } + + if v.Error != nil { + ch.Error = *v.Error + } + + comps[k] = ch + } + + s.Components = comps + } + + if g.Nats != nil { + s.NATS = &NATSInfo{ + URL: g.Nats.Url, + Version: g.Nats.Version, + } + } + + if g.Agents != nil { + as := &AgentStats{ + Total: g.Agents.Total, + Ready: g.Agents.Ready, + } + + if g.Agents.Agents != nil { + agents := make([]AgentSummary, 0, len(*g.Agents.Agents)) + for _, a := range *g.Agents.Agents { + summary := AgentSummary{ + Hostname: a.Hostname, + Registered: a.Registered, + } + + if a.Labels != nil { + summary.Labels = *a.Labels + } + + agents = append(agents, summary) + } + + as.Agents = agents + } + + s.Agents = as + } + + if g.Jobs != nil { + s.Jobs = &JobStats{ + Total: g.Jobs.Total, + Completed: g.Jobs.Completed, + Failed: g.Jobs.Failed, + Processing: g.Jobs.Processing, + Unprocessed: g.Jobs.Unprocessed, + Dlq: g.Jobs.Dlq, + } + } + + if g.Consumers != nil { + cs := &ConsumerStats{ + Total: g.Consumers.Total, + } + + if g.Consumers.Consumers != nil { + consumers := make([]ConsumerDetail, 0, len(*g.Consumers.Consumers)) + for _, c := range *g.Consumers.Consumers { + consumers = append(consumers, ConsumerDetail{ + Name: c.Name, + Pending: c.Pending, + AckPending: c.AckPending, + Redelivered: c.Redelivered, + }) + } + + cs.Consumers = consumers + } + + s.Consumers = cs + } + + if g.Streams != nil { + streams := make([]StreamInfo, 0, len(*g.Streams)) + for _, st := range *g.Streams { + streams = append(streams, StreamInfo{ + Name: st.Name, + Messages: st.Messages, + Bytes: st.Bytes, + Consumers: st.Consumers, + }) + } + + s.Streams = streams + } + + if g.KvBuckets != nil { + buckets := make([]KVBucketInfo, 0, len(*g.KvBuckets)) + for _, b := range *g.KvBuckets { + buckets = append(buckets, KVBucketInfo{ + Name: b.Name, + Keys: b.Keys, + Bytes: b.Bytes, + }) + } + + s.KVBuckets = buckets + } + + if g.ObjectStores != nil { + stores := make([]ObjectStoreInfo, 0, len(*g.ObjectStores)) + for _, o := range *g.ObjectStores { + stores = append(stores, ObjectStoreInfo{ + Name: o.Name, + Size: o.Size, + }) + } + + s.ObjectStores = stores + } + + return s +} diff --git a/pkg/sdk/osapi/health_types_test.go b/pkg/sdk/osapi/health_types_test.go new file mode 100644 index 00000000..0f68e254 --- /dev/null +++ b/pkg/sdk/osapi/health_types_test.go @@ -0,0 +1,351 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type HealthTypesTestSuite struct { + suite.Suite +} + +func (suite *HealthTypesTestSuite) TestHealthStatusFromGen() { + tests := []struct { + name string + input *gen.HealthResponse + validateFunc func(HealthStatus) + }{ + { + name: "when status is ok", + input: &gen.HealthResponse{ + Status: "ok", + }, + validateFunc: func(h HealthStatus) { + suite.Equal("ok", h.Status) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := healthStatusFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *HealthTypesTestSuite) TestReadyStatusFromGen() { + tests := []struct { + name string + input *gen.ReadyResponse + serviceUnavailable bool + validateFunc func(ReadyStatus) + }{ + { + name: "when ready with no error", + input: &gen.ReadyResponse{ + Status: "ready", + }, + serviceUnavailable: false, + validateFunc: func(r ReadyStatus) { + suite.Equal("ready", r.Status) + suite.Empty(r.Error) + suite.False(r.ServiceUnavailable) + }, + }, + { + name: "when not ready with error", + input: func() *gen.ReadyResponse { + errMsg := "NATS connection failed" + + return &gen.ReadyResponse{ + Status: "not_ready", + Error: &errMsg, + } + }(), + serviceUnavailable: true, + validateFunc: func(r ReadyStatus) { + suite.Equal("not_ready", r.Status) + suite.Equal("NATS connection failed", r.Error) + suite.True(r.ServiceUnavailable) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := readyStatusFromGen(tc.input, tc.serviceUnavailable) + tc.validateFunc(result) + }) + } +} + +func (suite *HealthTypesTestSuite) TestSystemStatusFromGen() { + tests := []struct { + name string + input *gen.StatusResponse + serviceUnavailable bool + validateFunc func(SystemStatus) + }{ + { + name: "when all fields are populated", + input: func() *gen.StatusResponse { + errMsg := "connection timeout" + labels := "group=web" + + return &gen.StatusResponse{ + Status: "degraded", + Version: "1.2.3", + Uptime: "5d 3h", + Components: map[string]gen.ComponentHealth{ + "nats": { + Status: "healthy", + }, + "store": { + Status: "unhealthy", + Error: &errMsg, + }, + }, + Nats: &gen.NATSInfo{ + Url: "nats://localhost:4222", + Version: "2.10.0", + }, + Agents: &gen.AgentStats{ + Total: 3, + Ready: 2, + Agents: &[]gen.AgentDetail{ + { + Hostname: "web-01", + Labels: &labels, + Registered: "5m ago", + }, + { + Hostname: "web-02", + Registered: "10m ago", + }, + }, + }, + Jobs: &gen.JobStats{ + Total: 100, + Completed: 80, + Failed: 5, + Processing: 10, + Unprocessed: 3, + Dlq: 2, + }, + Consumers: &gen.ConsumerStats{ + Total: 2, + Consumers: &[]gen.ConsumerDetail{ + { + Name: "jobs-agent", + Pending: 5, + AckPending: 2, + Redelivered: 1, + }, + }, + }, + Streams: &[]gen.StreamInfo{ + { + Name: "JOBS", + Messages: 500, + Bytes: 1048576, + Consumers: 2, + }, + }, + KvBuckets: &[]gen.KVBucketInfo{ + { + Name: "job-queue", + Keys: 50, + Bytes: 524288, + }, + { + Name: "audit-log", + Keys: 200, + Bytes: 2097152, + }, + }, + ObjectStores: &[]gen.ObjectStoreInfo{ + { + Name: "file-objects", + Size: 5242880, + }, + }, + } + }(), + serviceUnavailable: false, + validateFunc: func(s SystemStatus) { + suite.Equal("degraded", s.Status) + suite.Equal("1.2.3", s.Version) + suite.Equal("5d 3h", s.Uptime) + suite.False(s.ServiceUnavailable) + + suite.Require().Len(s.Components, 2) + suite.Equal("healthy", s.Components["nats"].Status) + suite.Empty(s.Components["nats"].Error) + suite.Equal("unhealthy", s.Components["store"].Status) + suite.Equal("connection timeout", s.Components["store"].Error) + + suite.Require().NotNil(s.NATS) + suite.Equal("nats://localhost:4222", s.NATS.URL) + suite.Equal("2.10.0", s.NATS.Version) + + suite.Require().NotNil(s.Agents) + suite.Equal(3, s.Agents.Total) + suite.Equal(2, s.Agents.Ready) + suite.Require().Len(s.Agents.Agents, 2) + suite.Equal("web-01", s.Agents.Agents[0].Hostname) + suite.Equal("group=web", s.Agents.Agents[0].Labels) + suite.Equal("5m ago", s.Agents.Agents[0].Registered) + suite.Equal("web-02", s.Agents.Agents[1].Hostname) + suite.Empty(s.Agents.Agents[1].Labels) + suite.Equal("10m ago", s.Agents.Agents[1].Registered) + + suite.Require().NotNil(s.Jobs) + suite.Equal(100, s.Jobs.Total) + suite.Equal(80, s.Jobs.Completed) + suite.Equal(5, s.Jobs.Failed) + suite.Equal(10, s.Jobs.Processing) + suite.Equal(3, s.Jobs.Unprocessed) + suite.Equal(2, s.Jobs.Dlq) + + suite.Require().NotNil(s.Consumers) + suite.Equal(2, s.Consumers.Total) + suite.Require().Len(s.Consumers.Consumers, 1) + suite.Equal("jobs-agent", s.Consumers.Consumers[0].Name) + suite.Equal(5, s.Consumers.Consumers[0].Pending) + suite.Equal(2, s.Consumers.Consumers[0].AckPending) + suite.Equal(1, s.Consumers.Consumers[0].Redelivered) + + suite.Require().Len(s.Streams, 1) + suite.Equal("JOBS", s.Streams[0].Name) + suite.Equal(500, s.Streams[0].Messages) + suite.Equal(1048576, s.Streams[0].Bytes) + suite.Equal(2, s.Streams[0].Consumers) + + suite.Require().Len(s.KVBuckets, 2) + suite.Equal("job-queue", s.KVBuckets[0].Name) + suite.Equal(50, s.KVBuckets[0].Keys) + suite.Equal(524288, s.KVBuckets[0].Bytes) + suite.Equal("audit-log", s.KVBuckets[1].Name) + suite.Equal(200, s.KVBuckets[1].Keys) + suite.Equal(2097152, s.KVBuckets[1].Bytes) + + suite.Require().Len(s.ObjectStores, 1) + suite.Equal("file-objects", s.ObjectStores[0].Name) + suite.Equal(5242880, s.ObjectStores[0].Size) + }, + }, + { + name: "when only required fields are set", + input: &gen.StatusResponse{ + Status: "ok", + Version: "1.0.0", + Uptime: "1h", + Components: map[string]gen.ComponentHealth{}, + }, + serviceUnavailable: false, + validateFunc: func(s SystemStatus) { + suite.Equal("ok", s.Status) + suite.Equal("1.0.0", s.Version) + suite.Equal("1h", s.Uptime) + suite.False(s.ServiceUnavailable) + suite.Empty(s.Components) + suite.Nil(s.NATS) + suite.Nil(s.Agents) + suite.Nil(s.Jobs) + suite.Nil(s.Consumers) + suite.Nil(s.Streams) + suite.Nil(s.KVBuckets) + suite.Nil(s.ObjectStores) + }, + }, + { + name: "when service unavailable is true", + input: &gen.StatusResponse{ + Status: "degraded", + Version: "1.0.0", + Uptime: "30m", + Components: map[string]gen.ComponentHealth{}, + }, + serviceUnavailable: true, + validateFunc: func(s SystemStatus) { + suite.Equal("degraded", s.Status) + suite.True(s.ServiceUnavailable) + }, + }, + { + name: "when agents has nil agents list", + input: &gen.StatusResponse{ + Status: "ok", + Version: "1.0.0", + Uptime: "1h", + Components: map[string]gen.ComponentHealth{ + "nats": {Status: "healthy"}, + }, + Agents: &gen.AgentStats{ + Total: 0, + Ready: 0, + }, + }, + serviceUnavailable: false, + validateFunc: func(s SystemStatus) { + suite.Require().NotNil(s.Agents) + suite.Equal(0, s.Agents.Total) + suite.Equal(0, s.Agents.Ready) + suite.Nil(s.Agents.Agents) + }, + }, + { + name: "when consumers has nil consumers list", + input: &gen.StatusResponse{ + Status: "ok", + Version: "1.0.0", + Uptime: "1h", + Components: map[string]gen.ComponentHealth{}, + Consumers: &gen.ConsumerStats{ + Total: 0, + }, + }, + serviceUnavailable: false, + validateFunc: func(s SystemStatus) { + suite.Require().NotNil(s.Consumers) + suite.Equal(0, s.Consumers.Total) + suite.Nil(s.Consumers.Consumers) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := systemStatusFromGen(tc.input, tc.serviceUnavailable) + tc.validateFunc(result) + }) + } +} + +func TestHealthTypesTestSuite(t *testing.T) { + suite.Run(t, new(HealthTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/job.go b/pkg/sdk/osapi/job.go new file mode 100644 index 00000000..8965cc50 --- /dev/null +++ b/pkg/sdk/osapi/job.go @@ -0,0 +1,226 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + + "github.com/google/uuid" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// JobService provides job queue operations. +type JobService struct { + client *gen.ClientWithResponses +} + +// Create creates a new job with the given operation and target. +func (s *JobService) Create( + ctx context.Context, + operation map[string]interface{}, + target string, +) (*Response[JobCreated], error) { + body := gen.CreateJobRequest{ + Operation: operation, + TargetHostname: target, + } + + resp, err := s.client.PostJobWithResponse(ctx, body) + if err != nil { + return nil, fmt.Errorf("create job: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON201 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(jobCreatedFromGen(resp.JSON201), resp.Body), nil +} + +// Get retrieves a job by ID. +func (s *JobService) Get( + ctx context.Context, + id string, +) (*Response[JobDetail], error) { + parsedID, err := uuid.Parse(id) + if err != nil { + return nil, fmt.Errorf("invalid job ID: %w", err) + } + + resp, err := s.client.GetJobByIDWithResponse(ctx, parsedID) + if err != nil { + return nil, fmt.Errorf("get job: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(jobDetailFromGen(resp.JSON200), resp.Body), nil +} + +// Delete deletes a job by ID. +func (s *JobService) Delete( + ctx context.Context, + id string, +) error { + parsedID, err := uuid.Parse(id) + if err != nil { + return fmt.Errorf("invalid job ID: %w", err) + } + + resp, err := s.client.DeleteJobByIDWithResponse(ctx, parsedID) + if err != nil { + return fmt.Errorf("delete job: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500); err != nil { + return err + } + + return nil +} + +// ListParams contains optional filters for listing jobs. +type ListParams struct { + // Status filters by job status (e.g., "pending", "completed"). + Status string + + // Limit is the maximum number of results. Zero uses server default. + Limit int + + // Offset is the number of results to skip. Zero starts from the + // beginning. + Offset int +} + +// List retrieves jobs, optionally filtered by status. +func (s *JobService) List( + ctx context.Context, + params ListParams, +) (*Response[JobList], error) { + p := &gen.GetJobParams{} + + if params.Status != "" { + status := gen.GetJobParamsStatus(params.Status) + p.Status = &status + } + + if params.Limit > 0 { + p.Limit = ¶ms.Limit + } + + if params.Offset > 0 { + p.Offset = ¶ms.Offset + } + + resp, err := s.client.GetJobWithResponse(ctx, p) + if err != nil { + return nil, fmt.Errorf("list jobs: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(jobListFromGen(resp.JSON200), resp.Body), nil +} + +// QueueStats retrieves job queue statistics. +func (s *JobService) QueueStats( + ctx context.Context, +) (*Response[QueueStats], error) { + resp, err := s.client.GetJobStatusWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("queue stats: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(queueStatsFromGen(resp.JSON200), resp.Body), nil +} + +// Retry retries a failed job by ID, optionally on a different target. +func (s *JobService) Retry( + ctx context.Context, + id string, + target string, +) (*Response[JobCreated], error) { + parsedID, err := uuid.Parse(id) + if err != nil { + return nil, fmt.Errorf("invalid job ID: %w", err) + } + + body := gen.RetryJobByIDJSONRequestBody{} + if target != "" { + body.TargetHostname = &target + } + + resp, err := s.client.RetryJobByIDWithResponse(ctx, parsedID, body) + if err != nil { + return nil, fmt.Errorf("retry job: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON404, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON201 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(jobCreatedFromGen(resp.JSON201), resp.Body), nil +} diff --git a/pkg/sdk/osapi/job_public_test.go b/pkg/sdk/osapi/job_public_test.go new file mode 100644 index 00000000..af3c5978 --- /dev/null +++ b/pkg/sdk/osapi/job_public_test.go @@ -0,0 +1,691 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type JobPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *JobPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *JobPublicTestSuite) TestCreate() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + operation map[string]interface{} + target string + validateFunc func(*osapi.Response[osapi.JobCreated], error) + }{ + { + name: "when creating job returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"job_id":"550e8400-e29b-41d4-a716-446655440000","status":"pending"}`, + ), + ) + }, + operation: map[string]interface{}{"type": "system.hostname.get"}, + target: "_any", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.JobID) + suite.Equal("pending", resp.Data.Status) + }, + }, + { + name: "when server returns 400 returns ValidationError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"validation failed"}`)) + }, + operation: map[string]interface{}{}, + target: "_any", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + operation: map[string]interface{}{}, + target: "_any", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "create job") + }, + }, + { + name: "when server returns 201 with empty body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + operation: map[string]interface{}{}, + target: "_any", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Job.Create(suite.ctx, tc.operation, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *JobPublicTestSuite) TestGet() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + id string + validateFunc func(*osapi.Response[osapi.JobDetail], error) + }{ + { + name: "when valid UUID returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"id":"550e8400-e29b-41d4-a716-446655440000","status":"completed"}`, + ), + ) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.ID) + suite.Equal("completed", resp.Data.Status) + }, + }, + { + name: "when invalid UUID returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"id":"550e8400-e29b-41d4-a716-446655440000","status":"completed"}`, + ), + ) + }, + id: "not-a-uuid", + validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "invalid job ID") + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + id: "00000000-0000-0000-0000-000000000000", + validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get job") + }, + }, + { + name: "when server returns 200 with empty body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + id: "00000000-0000-0000-0000-000000000000", + validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Contains(target.Message, "nil response body") + }, + }, + { + name: "when server returns 404 returns NotFoundError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"job not found"}`)) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + suite.Equal("job not found", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Job.Get(suite.ctx, tc.id) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *JobPublicTestSuite) TestDelete() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + id string + validateFunc func(error) + }{ + { + name: "when valid UUID returns no error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + validateFunc: func(err error) { + suite.NoError(err) + }, + }, + { + name: "when invalid UUID returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }, + id: "not-a-uuid", + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "invalid job ID") + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + id: "00000000-0000-0000-0000-000000000000", + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "delete job") + }, + }, + { + name: "when server returns 404 returns NotFoundError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"job not found"}`)) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + validateFunc: func(err error) { + suite.Error(err) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + suite.Equal("job not found", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + err := sut.Job.Delete(suite.ctx, tc.id) + tc.validateFunc(err) + }) + } +} + +func (suite *JobPublicTestSuite) TestList() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + params osapi.ListParams + validateFunc func(*osapi.Response[osapi.JobList], error) + }{ + { + name: "when no filters returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"items":[],"total_items":0}`)) + }, + params: osapi.ListParams{}, + validateFunc: func(resp *osapi.Response[osapi.JobList], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal(0, resp.Data.TotalItems) + suite.Empty(resp.Data.Items) + }, + }, + { + name: "when all filters provided returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"items":[],"total_items":0}`)) + }, + params: osapi.ListParams{ + Status: "completed", + Limit: 10, + Offset: 5, + }, + validateFunc: func(resp *osapi.Response[osapi.JobList], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + params: osapi.ListParams{}, + validateFunc: func(resp *osapi.Response[osapi.JobList], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "list jobs") + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + params: osapi.ListParams{}, + validateFunc: func(resp *osapi.Response[osapi.JobList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 200 with empty body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + params: osapi.ListParams{}, + validateFunc: func(resp *osapi.Response[osapi.JobList], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Job.List(suite.ctx, tc.params) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *JobPublicTestSuite) TestQueueStats() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + validateFunc func(*osapi.Response[osapi.QueueStats], error) + }{ + { + name: "when requesting queue stats returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"total_jobs":5}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal(5, resp.Data.TotalJobs) + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "queue stats") + }, + }, + { + name: "when server returns 401 returns AuthError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"unauthorized"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusUnauthorized, target.StatusCode) + }, + }, + { + name: "when server returns 200 with empty body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Job.QueueStats(suite.ctx) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *JobPublicTestSuite) TestRetry() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + id string + target string + validateFunc func(*osapi.Response[osapi.JobCreated], error) + }{ + { + name: "when valid UUID with empty target returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"job_id":"550e8400-e29b-41d4-a716-446655440000","status":"pending"}`, + ), + ) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + target: "", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.JobID) + suite.Equal("pending", resp.Data.Status) + }, + }, + { + name: "when valid UUID with target returns response", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"job_id":"550e8400-e29b-41d4-a716-446655440000","status":"pending"}`, + ), + ) + }, + id: "550e8400-e29b-41d4-a716-446655440000", + target: "web-01", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when invalid UUID returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write( + []byte( + `{"job_id":"550e8400-e29b-41d4-a716-446655440000","status":"pending"}`, + ), + ) + }, + id: "not-a-uuid", + target: "", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "invalid job ID") + }, + }, + { + name: "when HTTP request fails returns error", + serverURL: "http://127.0.0.1:0", + id: "00000000-0000-0000-0000-000000000000", + target: "", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "retry job") + }, + }, + { + name: "when server returns 404 returns NotFoundError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"job not found"}`)) + }, + id: "00000000-0000-0000-0000-000000000000", + target: "", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusNotFound, target.StatusCode) + }, + }, + { + name: "when server returns 201 with empty body returns UnexpectedStatusError", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }, + id: "00000000-0000-0000-0000-000000000000", + target: "", + validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Contains(target.Message, "nil response body") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + server *httptest.Server + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + } else { + server = httptest.NewServer(tc.handler) + defer server.Close() + serverURL = server.URL + } + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Job.Retry(suite.ctx, tc.id, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func TestJobPublicTestSuite(t *testing.T) { + suite.Run(t, new(JobPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/job_types.go b/pkg/sdk/osapi/job_types.go new file mode 100644 index 00000000..6d3ee5a8 --- /dev/null +++ b/pkg/sdk/osapi/job_types.go @@ -0,0 +1,262 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// JobCreated represents a newly created job response. +type JobCreated struct { + JobID string + Status string + Revision int64 + Timestamp string +} + +// JobDetail represents a job's full details. +type JobDetail struct { + ID string + Status string + Hostname string + Created string + UpdatedAt string + Error string + Operation map[string]any + Result any + AgentStates map[string]AgentState + Responses map[string]AgentJobResponse + Timeline []TimelineEvent +} + +// AgentState represents an agent's processing state for a broadcast job. +type AgentState struct { + Status string + Duration string + Error string +} + +// AgentJobResponse represents an agent's response data for a broadcast job. +type AgentJobResponse struct { + Hostname string + Status string + Error string + Data any +} + +// JobList is a paginated list of jobs. +type JobList struct { + Items []JobDetail + TotalItems int + StatusCounts map[string]int +} + +// QueueStats represents job queue statistics. +type QueueStats struct { + TotalJobs int + DlqCount int + StatusCounts map[string]int +} + +// jobCreatedFromGen converts a gen.CreateJobResponse to a JobCreated. +func jobCreatedFromGen( + g *gen.CreateJobResponse, +) JobCreated { + j := JobCreated{ + JobID: g.JobId.String(), + Status: g.Status, + } + + if g.Revision != nil { + j.Revision = *g.Revision + } + + if g.Timestamp != nil { + j.Timestamp = *g.Timestamp + } + + return j +} + +// jobDetailFromGen converts a gen.JobDetailResponse to a JobDetail. +func jobDetailFromGen( + g *gen.JobDetailResponse, +) JobDetail { + j := JobDetail{} + + if g.Id != nil { + j.ID = g.Id.String() + } + + if g.Status != nil { + j.Status = *g.Status + } + + if g.Hostname != nil { + j.Hostname = *g.Hostname + } + + if g.Created != nil { + j.Created = *g.Created + } + + if g.UpdatedAt != nil { + j.UpdatedAt = *g.UpdatedAt + } + + if g.Error != nil { + j.Error = *g.Error + } + + if g.Operation != nil { + j.Operation = *g.Operation + } + + j.Result = g.Result + + if g.AgentStates != nil { + states := make(map[string]AgentState, len(*g.AgentStates)) + for k, v := range *g.AgentStates { + as := AgentState{} + + if v.Status != nil { + as.Status = *v.Status + } + + if v.Duration != nil { + as.Duration = *v.Duration + } + + if v.Error != nil { + as.Error = *v.Error + } + + states[k] = as + } + + j.AgentStates = states + } + + if g.Responses != nil { + responses := make(map[string]AgentJobResponse, len(*g.Responses)) + for k, v := range *g.Responses { + r := AgentJobResponse{ + Data: v.Data, + } + + if v.Hostname != nil { + r.Hostname = *v.Hostname + } + + if v.Status != nil { + r.Status = *v.Status + } + + if v.Error != nil { + r.Error = *v.Error + } + + responses[k] = r + } + + j.Responses = responses + } + + if g.Timeline != nil { + timeline := make([]TimelineEvent, 0, len(*g.Timeline)) + for _, v := range *g.Timeline { + te := TimelineEvent{} + + if v.Timestamp != nil { + te.Timestamp = *v.Timestamp + } + + if v.Event != nil { + te.Event = *v.Event + } + + if v.Hostname != nil { + te.Hostname = *v.Hostname + } + + if v.Message != nil { + te.Message = *v.Message + } + + if v.Error != nil { + te.Error = *v.Error + } + + timeline = append(timeline, te) + } + + j.Timeline = timeline + } + + return j +} + +// jobListFromGen converts a gen.ListJobsResponse to a JobList. +func jobListFromGen( + g *gen.ListJobsResponse, +) JobList { + jl := JobList{} + + if g.TotalItems != nil { + jl.TotalItems = *g.TotalItems + } + + if g.StatusCounts != nil { + jl.StatusCounts = *g.StatusCounts + } + + if g.Items != nil { + items := make([]JobDetail, 0, len(*g.Items)) + for i := range *g.Items { + items = append(items, jobDetailFromGen(&(*g.Items)[i])) + } + + jl.Items = items + } + + return jl +} + +// queueStatsFromGen converts a gen.QueueStatsResponse to QueueStats. +func queueStatsFromGen( + g *gen.QueueStatsResponse, +) QueueStats { + qs := QueueStats{} + + if g.TotalJobs != nil { + qs.TotalJobs = *g.TotalJobs + } + + if g.DlqCount != nil { + qs.DlqCount = *g.DlqCount + } + + if g.StatusCounts != nil { + qs.StatusCounts = *g.StatusCounts + } + + return qs +} diff --git a/pkg/sdk/osapi/job_types_test.go b/pkg/sdk/osapi/job_types_test.go new file mode 100644 index 00000000..430c99ab --- /dev/null +++ b/pkg/sdk/osapi/job_types_test.go @@ -0,0 +1,361 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type JobTypesTestSuite struct { + suite.Suite +} + +func (suite *JobTypesTestSuite) TestJobCreatedFromGen() { + tests := []struct { + name string + input *gen.CreateJobResponse + validateFunc func(JobCreated) + }{ + { + name: "when all fields are populated", + input: func() *gen.CreateJobResponse { + rev := int64(42) + ts := "2026-03-04T12:00:00Z" + return &gen.CreateJobResponse{ + JobId: openapi_types.UUID( + uuid.MustParse("11111111-1111-1111-1111-111111111111"), + ), + Status: "pending", + Revision: &rev, + Timestamp: &ts, + } + }(), + validateFunc: func(j JobCreated) { + suite.Equal("11111111-1111-1111-1111-111111111111", j.JobID) + suite.Equal("pending", j.Status) + suite.Equal(int64(42), j.Revision) + suite.Equal("2026-03-04T12:00:00Z", j.Timestamp) + }, + }, + { + name: "when optional fields are nil", + input: &gen.CreateJobResponse{ + JobId: openapi_types.UUID(uuid.MustParse("22222222-2222-2222-2222-222222222222")), + Status: "pending", + }, + validateFunc: func(j JobCreated) { + suite.Equal("22222222-2222-2222-2222-222222222222", j.JobID) + suite.Equal("pending", j.Status) + suite.Equal(int64(0), j.Revision) + suite.Empty(j.Timestamp) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := jobCreatedFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *JobTypesTestSuite) TestJobDetailFromGen() { + tests := []struct { + name string + input *gen.JobDetailResponse + validateFunc func(JobDetail) + }{ + { + name: "when all fields are populated with agent states responses and timeline", + input: func() *gen.JobDetailResponse { + id := openapi_types.UUID(uuid.MustParse("33333333-3333-3333-3333-333333333333")) + status := "completed" + hostname := "web-01" + created := "2026-03-04T12:00:00Z" + updatedAt := "2026-03-04T12:01:00Z" + errMsg := "something failed" + operation := map[string]interface{}{"type": "node.hostname"} + result := map[string]interface{}{"hostname": "web-01"} + + agentStatus := "completed" + agentDuration := "1.5s" + agentError := "" + agentStates := map[string]struct { + Duration *string `json:"duration,omitempty"` + Error *string `json:"error,omitempty"` + Status *string `json:"status,omitempty"` + }{ + "web-01": { + Status: &agentStatus, + Duration: &agentDuration, + Error: &agentError, + }, + } + + respHostname := "web-01" + respStatus := "completed" + respError := "" + respData := map[string]interface{}{"hostname": "web-01"} + responses := map[string]struct { + Data interface{} `json:"data,omitempty"` + Error *string `json:"error,omitempty"` + Hostname *string `json:"hostname,omitempty"` + Status *string `json:"status,omitempty"` + }{ + "web-01": { + Hostname: &respHostname, + Status: &respStatus, + Error: &respError, + Data: respData, + }, + } + + tlTimestamp := "2026-03-04T12:00:00Z" + tlEvent := "submitted" + tlHostname := "api-server" + tlMessage := "Job submitted" + tlError := "" + timeline := []struct { + Error *string `json:"error,omitempty"` + Event *string `json:"event,omitempty"` + Hostname *string `json:"hostname,omitempty"` + Message *string `json:"message,omitempty"` + Timestamp *string `json:"timestamp,omitempty"` + }{ + { + Timestamp: &tlTimestamp, + Event: &tlEvent, + Hostname: &tlHostname, + Message: &tlMessage, + Error: &tlError, + }, + } + + return &gen.JobDetailResponse{ + Id: &id, + Status: &status, + Hostname: &hostname, + Created: &created, + UpdatedAt: &updatedAt, + Error: &errMsg, + Operation: &operation, + Result: result, + AgentStates: &agentStates, + Responses: &responses, + Timeline: &timeline, + } + }(), + validateFunc: func(j JobDetail) { + suite.Equal("33333333-3333-3333-3333-333333333333", j.ID) + suite.Equal("completed", j.Status) + suite.Equal("web-01", j.Hostname) + suite.Equal("2026-03-04T12:00:00Z", j.Created) + suite.Equal("2026-03-04T12:01:00Z", j.UpdatedAt) + suite.Equal("something failed", j.Error) + suite.Equal(map[string]interface{}{"type": "node.hostname"}, j.Operation) + suite.Equal(map[string]interface{}{"hostname": "web-01"}, j.Result) + + suite.Len(j.AgentStates, 1) + suite.Equal("completed", j.AgentStates["web-01"].Status) + suite.Equal("1.5s", j.AgentStates["web-01"].Duration) + suite.Empty(j.AgentStates["web-01"].Error) + + suite.Len(j.Responses, 1) + suite.Equal("web-01", j.Responses["web-01"].Hostname) + suite.Equal("completed", j.Responses["web-01"].Status) + suite.Empty(j.Responses["web-01"].Error) + suite.Equal( + map[string]interface{}{"hostname": "web-01"}, + j.Responses["web-01"].Data, + ) + + suite.Len(j.Timeline, 1) + suite.Equal("2026-03-04T12:00:00Z", j.Timeline[0].Timestamp) + suite.Equal("submitted", j.Timeline[0].Event) + suite.Equal("api-server", j.Timeline[0].Hostname) + suite.Equal("Job submitted", j.Timeline[0].Message) + suite.Empty(j.Timeline[0].Error) + }, + }, + { + name: "when all fields are nil", + input: &gen.JobDetailResponse{}, + validateFunc: func(j JobDetail) { + suite.Empty(j.ID) + suite.Empty(j.Status) + suite.Empty(j.Hostname) + suite.Empty(j.Created) + suite.Empty(j.UpdatedAt) + suite.Empty(j.Error) + suite.Nil(j.Operation) + suite.Nil(j.Result) + suite.Nil(j.AgentStates) + suite.Nil(j.Responses) + suite.Nil(j.Timeline) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := jobDetailFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *JobTypesTestSuite) TestJobListFromGen() { + tests := []struct { + name string + input *gen.ListJobsResponse + validateFunc func(JobList) + }{ + { + name: "when items are present", + input: func() *gen.ListJobsResponse { + id := openapi_types.UUID(uuid.MustParse("44444444-4444-4444-4444-444444444444")) + status := "pending" + totalItems := 1 + statusCounts := map[string]int{ + "pending": 1, + "completed": 0, + } + items := []gen.JobDetailResponse{ + { + Id: &id, + Status: &status, + }, + } + + return &gen.ListJobsResponse{ + Items: &items, + TotalItems: &totalItems, + StatusCounts: &statusCounts, + } + }(), + validateFunc: func(jl JobList) { + suite.Equal(1, jl.TotalItems) + suite.Equal(map[string]int{ + "pending": 1, + "completed": 0, + }, jl.StatusCounts) + suite.Len(jl.Items, 1) + suite.Equal("44444444-4444-4444-4444-444444444444", jl.Items[0].ID) + suite.Equal("pending", jl.Items[0].Status) + }, + }, + { + name: "when items are empty", + input: func() *gen.ListJobsResponse { + totalItems := 0 + items := []gen.JobDetailResponse{} + + return &gen.ListJobsResponse{ + Items: &items, + TotalItems: &totalItems, + } + }(), + validateFunc: func(jl JobList) { + suite.Equal(0, jl.TotalItems) + suite.Empty(jl.Items) + }, + }, + { + name: "when all fields are nil", + input: &gen.ListJobsResponse{}, + validateFunc: func(jl JobList) { + suite.Equal(0, jl.TotalItems) + suite.Nil(jl.StatusCounts) + suite.Nil(jl.Items) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := jobListFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *JobTypesTestSuite) TestQueueStatsFromGen() { + tests := []struct { + name string + input *gen.QueueStatsResponse + validateFunc func(QueueStats) + }{ + { + name: "when all fields are populated", + input: func() *gen.QueueStatsResponse { + totalJobs := 100 + dlqCount := 5 + statusCounts := map[string]int{ + "pending": 30, + "completed": 60, + "failed": 10, + } + + return &gen.QueueStatsResponse{ + TotalJobs: &totalJobs, + DlqCount: &dlqCount, + StatusCounts: &statusCounts, + } + }(), + validateFunc: func(qs QueueStats) { + suite.Equal(100, qs.TotalJobs) + suite.Equal(5, qs.DlqCount) + suite.Equal(map[string]int{ + "pending": 30, + "completed": 60, + "failed": 10, + }, qs.StatusCounts) + }, + }, + { + name: "when all fields are nil", + input: &gen.QueueStatsResponse{}, + validateFunc: func(qs QueueStats) { + suite.Equal(0, qs.TotalJobs) + suite.Equal(0, qs.DlqCount) + suite.Nil(qs.StatusCounts) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := queueStatsFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func TestJobTypesTestSuite(t *testing.T) { + suite.Run(t, new(JobTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/metrics.go b/pkg/sdk/osapi/metrics.go new file mode 100644 index 00000000..2af459a0 --- /dev/null +++ b/pkg/sdk/osapi/metrics.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// MetricsService provides Prometheus metrics access. +type MetricsService struct { + client *gen.ClientWithResponses + baseURL string +} + +// Get fetches the raw Prometheus metrics text from the /metrics endpoint. +func (s *MetricsService) Get( + ctx context.Context, +) (string, error) { + url := strings.TrimRight(s.baseURL, "/") + "/metrics" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", fmt.Errorf("creating metrics request: %w", err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("fetching metrics: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("metrics endpoint returned status %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading metrics response: %w", err) + } + + return string(body), nil +} diff --git a/pkg/sdk/osapi/metrics_public_test.go b/pkg/sdk/osapi/metrics_public_test.go new file mode 100644 index 00000000..ec31309c --- /dev/null +++ b/pkg/sdk/osapi/metrics_public_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type MetricsPublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *MetricsPublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *MetricsPublicTestSuite) TestGet() { + closedServer := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + ) + closedServerURL := closedServer.URL + closedServer.Close() + + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + ctx context.Context + validateFunc func(string, error) + }{ + { + name: "when server returns metrics returns text body", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("# HELP go_goroutines\n")) + }, + ctx: suite.ctx, + validateFunc: func(body string, err error) { + suite.NoError(err) + suite.Equal("# HELP go_goroutines\n", body) + }, + }, + { + name: "when server returns non-200 returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + ctx: suite.ctx, + validateFunc: func(body string, err error) { + suite.Error(err) + suite.Contains(err.Error(), "metrics endpoint returned status") + suite.Empty(body) + }, + }, + { + name: "when server is unreachable returns error", + serverURL: closedServerURL, + ctx: suite.ctx, + validateFunc: func(body string, err error) { + suite.Error(err) + suite.Contains(err.Error(), "fetching metrics") + suite.Empty(body) + }, + }, + { + name: "when request creation fails returns error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ctx: nil, + validateFunc: func(body string, err error) { + suite.Error(err) + suite.Contains(err.Error(), "creating metrics request") + suite.Empty(body) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var targetURL string + + if tc.serverURL != "" { + targetURL = tc.serverURL + } else { + server := httptest.NewServer(tc.handler) + defer server.Close() + targetURL = server.URL + } + + sut := osapi.New( + targetURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + //nolint:staticcheck // nil context intentionally triggers NewRequestWithContext error + body, err := sut.Metrics.Get(tc.ctx) + tc.validateFunc(body, err) + }) + } +} + +func TestMetricsPublicTestSuite(t *testing.T) { + suite.Run(t, new(MetricsPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/metrics_test.go b/pkg/sdk/osapi/metrics_test.go new file mode 100644 index 00000000..9560777a --- /dev/null +++ b/pkg/sdk/osapi/metrics_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" +) + +type errorReader struct{} + +func (e *errorReader) Read( + _ []byte, +) (int, error) { + return 0, fmt.Errorf("read error") +} + +func (e *errorReader) Close() error { + return nil +} + +type MetricsTestSuite struct { + suite.Suite +} + +func (s *MetricsTestSuite) TestGetReadBodyError() { + tests := []struct { + name string + validateFunc func(string, error) + }{ + { + name: "when body read fails returns error", + validateFunc: func(body string, err error) { + s.Error(err) + s.Contains(err.Error(), "reading metrics response") + s.Empty(body) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + server := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Length", "100") + w.WriteHeader(http.StatusOK) + }), + ) + defer server.Close() + + sut := &MetricsService{ + baseURL: server.URL, + } + + origTransport := http.DefaultTransport + http.DefaultTransport = &readErrorTransport{} + defer func() { http.DefaultTransport = origTransport }() + + body, err := sut.Get(context.Background()) + tt.validateFunc(body, err) + }) + } +} + +type readErrorTransport struct{} + +func (t *readErrorTransport) RoundTrip( + req *http.Request, +) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(&errorReader{}), + Request: req, + }, nil +} + +func TestMetricsTestSuite(t *testing.T) { + suite.Run(t, new(MetricsTestSuite)) +} diff --git a/pkg/sdk/osapi/node.go b/pkg/sdk/osapi/node.go new file mode 100644 index 00000000..655370ca --- /dev/null +++ b/pkg/sdk/osapi/node.go @@ -0,0 +1,512 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "context" + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// NodeService provides node management operations. +type NodeService struct { + client *gen.ClientWithResponses +} + +// ExecRequest contains parameters for direct command execution. +type ExecRequest struct { + // Command is the binary to execute (required). + Command string + + // Args is the argument list passed to the command. + Args []string + + // Cwd is the working directory. Empty uses the agent default. + Cwd string + + // Timeout in seconds. Zero uses the server default (30s). + Timeout int + + // Target specifies the host: "_any", "_all", hostname, or + // label ("group:web"). + Target string +} + +// FileDeployOpts contains parameters for file deployment. +type FileDeployOpts struct { + // ObjectName is the name of the file in the Object Store (required). + ObjectName string + + // Path is the destination path on the target filesystem (required). + Path string + + // ContentType is "raw" or "template" (required). + ContentType string + + // Mode is the file permission mode (e.g., "0644"). Optional. + Mode string + + // Owner is the file owner user. Optional. + Owner string + + // Group is the file owner group. Optional. + Group string + + // Vars are template variables when ContentType is "template". Optional. + Vars map[string]any + + // Target specifies the host: "_any", "_all", hostname, or + // label ("group:web"). + Target string +} + +// ShellRequest contains parameters for shell command execution. +type ShellRequest struct { + // Command is the shell command string passed to /bin/sh -c (required). + Command string + + // Cwd is the working directory. Empty uses the agent default. + Cwd string + + // Timeout in seconds. Zero uses the server default (30s). + Timeout int + + // Target specifies the host: "_any", "_all", hostname, or + // label ("group:web"). + Target string +} + +// Status retrieves node status (OS info, disk, memory, load) from the +// target host. +func (s *NodeService) Status( + ctx context.Context, + target string, +) (*Response[Collection[NodeStatus]], error) { + resp, err := s.client.GetNodeStatusWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get status: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(nodeStatusCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Hostname retrieves the hostname from the target host. +func (s *NodeService) Hostname( + ctx context.Context, + target string, +) (*Response[Collection[HostnameResult]], error) { + resp, err := s.client.GetNodeHostnameWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get hostname: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(hostnameCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Disk retrieves disk usage information from the target host. +func (s *NodeService) Disk( + ctx context.Context, + target string, +) (*Response[Collection[DiskResult]], error) { + resp, err := s.client.GetNodeDiskWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get disk: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(diskCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Memory retrieves memory usage information from the target host. +func (s *NodeService) Memory( + ctx context.Context, + target string, +) (*Response[Collection[MemoryResult]], error) { + resp, err := s.client.GetNodeMemoryWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get memory: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(memoryCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Load retrieves load average information from the target host. +func (s *NodeService) Load( + ctx context.Context, + target string, +) (*Response[Collection[LoadResult]], error) { + resp, err := s.client.GetNodeLoadWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get load: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(loadCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// OS retrieves operating system information from the target host. +func (s *NodeService) OS( + ctx context.Context, + target string, +) (*Response[Collection[OSInfoResult]], error) { + resp, err := s.client.GetNodeOSWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get os: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(osInfoCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Uptime retrieves uptime information from the target host. +func (s *NodeService) Uptime( + ctx context.Context, + target string, +) (*Response[Collection[UptimeResult]], error) { + resp, err := s.client.GetNodeUptimeWithResponse(ctx, target) + if err != nil { + return nil, fmt.Errorf("get uptime: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(uptimeCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// GetDNS retrieves DNS configuration for a network interface on the +// target host. +func (s *NodeService) GetDNS( + ctx context.Context, + target string, + interfaceName string, +) (*Response[Collection[DNSConfig]], error) { + resp, err := s.client.GetNodeNetworkDNSByInterfaceWithResponse(ctx, target, interfaceName) + if err != nil { + return nil, fmt.Errorf("get dns: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(dnsConfigCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// UpdateDNS updates DNS configuration for a network interface on the +// target host. +func (s *NodeService) UpdateDNS( + ctx context.Context, + target string, + interfaceName string, + servers []string, + searchDomains []string, +) (*Response[Collection[DNSUpdateResult]], error) { + body := gen.DNSConfigUpdateRequest{ + InterfaceName: interfaceName, + } + + if len(servers) > 0 { + body.Servers = &servers + } + + if len(searchDomains) > 0 { + body.SearchDomains = &searchDomains + } + + resp, err := s.client.PutNodeNetworkDNSWithResponse(ctx, target, body) + if err != nil { + return nil, fmt.Errorf("update dns: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON202 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(dnsUpdateCollectionFromGen(resp.JSON202), resp.Body), nil +} + +// Ping sends an ICMP ping to the specified address from the target host. +func (s *NodeService) Ping( + ctx context.Context, + target string, + address string, +) (*Response[Collection[PingResult]], error) { + body := gen.PostNodeNetworkPingJSONRequestBody{ + Address: address, + } + + resp, err := s.client.PostNodeNetworkPingWithResponse(ctx, target, body) + if err != nil { + return nil, fmt.Errorf("ping: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(pingCollectionFromGen(resp.JSON200), resp.Body), nil +} + +// Exec executes a command directly without a shell interpreter. +func (s *NodeService) Exec( + ctx context.Context, + req ExecRequest, +) (*Response[Collection[CommandResult]], error) { + body := gen.CommandExecRequest{ + Command: req.Command, + } + + if len(req.Args) > 0 { + body.Args = &req.Args + } + + if req.Cwd != "" { + body.Cwd = &req.Cwd + } + + if req.Timeout > 0 { + body.Timeout = &req.Timeout + } + + resp, err := s.client.PostNodeCommandExecWithResponse(ctx, req.Target, body) + if err != nil { + return nil, fmt.Errorf("exec command: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON202 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(commandCollectionFromGen(resp.JSON202), resp.Body), nil +} + +// Shell executes a command through /bin/sh -c with shell features +// (pipes, redirects, variable expansion). +func (s *NodeService) Shell( + ctx context.Context, + req ShellRequest, +) (*Response[Collection[CommandResult]], error) { + body := gen.CommandShellRequest{ + Command: req.Command, + } + + if req.Cwd != "" { + body.Cwd = &req.Cwd + } + + if req.Timeout > 0 { + body.Timeout = &req.Timeout + } + + resp, err := s.client.PostNodeCommandShellWithResponse(ctx, req.Target, body) + if err != nil { + return nil, fmt.Errorf("shell command: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON202 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(commandCollectionFromGen(resp.JSON202), resp.Body), nil +} + +// FileDeploy deploys a file from the Object Store to the target host. +func (s *NodeService) FileDeploy( + ctx context.Context, + req FileDeployOpts, +) (*Response[FileDeployResult], error) { + body := gen.FileDeployRequest{ + ObjectName: req.ObjectName, + Path: req.Path, + ContentType: gen.FileDeployRequestContentType(req.ContentType), + } + + if req.Mode != "" { + body.Mode = &req.Mode + } + + if req.Owner != "" { + body.Owner = &req.Owner + } + + if req.Group != "" { + body.Group = &req.Group + } + + if len(req.Vars) > 0 { + body.Vars = &req.Vars + } + + resp, err := s.client.PostNodeFileDeployWithResponse(ctx, req.Target, body) + if err != nil { + return nil, fmt.Errorf("file deploy: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON202 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileDeployResultFromGen(resp.JSON202), resp.Body), nil +} + +// FileStatus checks the deployment status of a file on the target host. +func (s *NodeService) FileStatus( + ctx context.Context, + target string, + path string, +) (*Response[FileStatusResult], error) { + body := gen.FileStatusRequest{ + Path: path, + } + + resp, err := s.client.PostNodeFileStatusWithResponse(ctx, target, body) + if err != nil { + return nil, fmt.Errorf("file status: %w", err) + } + + if err := checkError(resp.StatusCode(), resp.JSON400, resp.JSON401, resp.JSON403, resp.JSON500); err != nil { + return nil, err + } + + if resp.JSON200 == nil { + return nil, &UnexpectedStatusError{APIError{ + StatusCode: resp.StatusCode(), + Message: "nil response body", + }} + } + + return NewResponse(fileStatusResultFromGen(resp.JSON200), resp.Body), nil +} diff --git a/pkg/sdk/osapi/node_public_test.go b/pkg/sdk/osapi/node_public_test.go new file mode 100644 index 00000000..4df60658 --- /dev/null +++ b/pkg/sdk/osapi/node_public_test.go @@ -0,0 +1,1693 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "context" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type NodePublicTestSuite struct { + suite.Suite + + ctx context.Context +} + +func (suite *NodePublicTestSuite) SetupTest() { + suite.ctx = context.Background() +} + +func (suite *NodePublicTestSuite) TestHostname() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.HostnameResult]], error) + }{ + { + name: "when requesting hostname returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"test-host"}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID) + suite.Len(resp.Data.Results, 1) + suite.Equal("test-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get hostname") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Hostname(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestStatus() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.NodeStatus]], error) + }{ + { + name: "when requesting status returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"hostname":"web-01"}]}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("web-01", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get status") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Status(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestDisk() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.DiskResult]], error) + }{ + { + name: "when requesting disk returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"hostname":"disk-host"}]}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("disk-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get disk") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Disk(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestMemory() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.MemoryResult]], error) + }{ + { + name: "when requesting memory returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"hostname":"mem-host"}]}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("mem-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get memory") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Memory(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestLoad() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.LoadResult]], error) + }{ + { + name: "when requesting load returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"hostname":"load-host"}]}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("load-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get load") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Load(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestOS() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.OSInfoResult]], error) + }{ + { + name: "when requesting OS info returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"results":[{"hostname":"os-host"}]}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("os-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get os") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.OS(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestUptime() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + validateFunc func(*osapi.Response[osapi.Collection[osapi.UptimeResult]], error) + }{ + { + name: "when requesting uptime returns results", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte(`{"results":[{"hostname":"uptime-host","uptime":"2d3h"}]}`), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("uptime-host", resp.Data.Results[0].Hostname) + suite.Equal("2d3h", resp.Data.Results[0].Uptime) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get uptime") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Uptime(suite.ctx, tc.target) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestGetDNS() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + interfaceName string + validateFunc func(*osapi.Response[osapi.Collection[osapi.DNSConfig]], error) + }{ + { + name: "when requesting DNS returns results", + target: "_any", + interfaceName: "eth0", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte(`{"results":[{"hostname":"dns-host","servers":["8.8.8.8"]}]}`), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("dns-host", resp.Data.Results[0].Hostname) + suite.Equal([]string{"8.8.8.8"}, resp.Data.Results[0].Servers) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + interfaceName: "eth0", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + interfaceName: "eth0", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "get dns") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + interfaceName: "eth0", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.GetDNS(suite.ctx, tc.target, tc.interfaceName) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestUpdateDNS() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + iface string + servers []string + searchDomains []string + validateFunc func(*osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], error) + }{ + { + name: "when servers only provided sets servers", + target: "_any", + iface: "eth0", + servers: []string{"8.8.8.8", "8.8.4.4"}, + searchDomains: nil, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"dns-host","status":"completed","changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("dns-host", resp.Data.Results[0].Hostname) + suite.Equal("completed", resp.Data.Results[0].Status) + suite.True(resp.Data.Results[0].Changed) + }, + }, + { + name: "when search domains only provided sets search domains", + target: "_any", + iface: "eth0", + servers: nil, + searchDomains: []string{"example.com"}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"dns-host","status":"completed","changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when both provided sets servers and search domains", + target: "_any", + iface: "eth0", + servers: []string{"8.8.8.8"}, + searchDomains: []string{"example.com"}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"dns-host","status":"completed","changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when neither provided sends empty body", + target: "_any", + iface: "eth0", + servers: nil, + searchDomains: nil, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"dns-host","status":"completed","changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + iface: "eth0", + servers: []string{"8.8.8.8"}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + iface: "eth0", + servers: []string{"8.8.8.8"}, + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "update dns") + }, + }, + { + name: "when server returns 202 with no JSON body returns UnexpectedStatusError", + target: "_any", + iface: "eth0", + servers: []string{"8.8.8.8"}, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusAccepted, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.UpdateDNS( + suite.ctx, + tc.target, + tc.iface, + tc.servers, + tc.searchDomains, + ) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestPing() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + address string + validateFunc func(*osapi.Response[osapi.Collection[osapi.PingResult]], error) + }{ + { + name: "when pinging address returns results", + target: "_any", + address: "8.8.8.8", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"ping-host","packets_sent":4,"packets_received":4,"packet_loss":0.0}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("ping-host", resp.Data.Results[0].Hostname) + suite.Equal(4, resp.Data.Results[0].PacketsSent) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + address: "8.8.8.8", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + address: "8.8.8.8", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "ping") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + address: "8.8.8.8", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Ping(suite.ctx, tc.target, tc.address) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestExec() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + req osapi.ExecRequest + validateFunc func(*osapi.Response[osapi.Collection[osapi.CommandResult]], error) + }{ + { + name: "when basic command returns results", + req: osapi.ExecRequest{ + Command: "whoami", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"exec-host","stdout":"root\n","exit_code":0,"changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("exec-host", resp.Data.Results[0].Hostname) + suite.Equal("root\n", resp.Data.Results[0].Stdout) + suite.Equal(0, resp.Data.Results[0].ExitCode) + }, + }, + { + name: "when all options provided returns results", + req: osapi.ExecRequest{ + Command: "ls", + Args: []string{"-la", "/tmp"}, + Cwd: "/tmp", + Timeout: 10, + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"exec-host","stdout":"root\n","exit_code":0,"changed":true}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when server returns 400 returns ValidationError", + req: osapi.ExecRequest{ + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"command is required"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + req: osapi.ExecRequest{ + Command: "whoami", + Target: "_any", + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "exec command") + }, + }, + { + name: "when server returns 202 with no JSON body returns UnexpectedStatusError", + req: osapi.ExecRequest{ + Command: "whoami", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusAccepted, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Exec(suite.ctx, tc.req) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestShell() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + req osapi.ShellRequest + validateFunc func(*osapi.Response[osapi.Collection[osapi.CommandResult]], error) + }{ + { + name: "when basic command returns results", + req: osapi.ShellRequest{ + Command: "uname -a", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"shell-host","exit_code":0,"changed":false}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Len(resp.Data.Results, 1) + suite.Equal("shell-host", resp.Data.Results[0].Hostname) + }, + }, + { + name: "when cwd and timeout provided returns results", + req: osapi.ShellRequest{ + Command: "ls -la", + Cwd: "/var/log", + Timeout: 15, + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"results":[{"hostname":"shell-host","exit_code":0,"changed":false}]}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when server returns 400 returns ValidationError", + req: osapi.ShellRequest{ + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"command is required"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + req: osapi.ShellRequest{ + Command: "uname -a", + Target: "_any", + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "shell command") + }, + }, + { + name: "when server returns 202 with no JSON body returns UnexpectedStatusError", + req: osapi.ShellRequest{ + Command: "uname -a", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusAccepted, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.Shell(suite.ctx, tc.req) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestFileDeploy() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + req osapi.FileDeployOpts + validateFunc func(*osapi.Response[osapi.FileDeployResult], error) + }{ + { + name: "when deploying file returns result", + req: osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"job-123","hostname":"web-01","changed":true}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("job-123", resp.Data.JobID) + suite.Equal("web-01", resp.Data.Hostname) + suite.True(resp.Data.Changed) + }, + }, + { + name: "when all options provided returns results", + req: osapi.FileDeployOpts{ + ObjectName: "app.conf.tmpl", + Path: "/etc/app/app.conf", + ContentType: "template", + Mode: "0644", + Owner: "root", + Group: "root", + Vars: map[string]any{"port": 8080}, + Target: "web-01", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write( + []byte( + `{"job_id":"job-456","hostname":"web-01","changed":true}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.NoError(err) + suite.NotNil(resp) + }, + }, + { + name: "when server returns 400 returns ValidationError", + req: osapi.FileDeployOpts{ + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"object_name is required"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + req: osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + serverURL: "http://127.0.0.1:0", + req: osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Target: "_any", + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "file deploy") + }, + }, + { + name: "when server returns 202 with no JSON body returns UnexpectedStatusError", + req: osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Target: "_any", + }, + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }, + validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusAccepted, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.FileDeploy(suite.ctx, tc.req) + tc.validateFunc(resp, err) + }) + } +} + +func (suite *NodePublicTestSuite) TestFileStatus() { + tests := []struct { + name string + handler http.HandlerFunc + serverURL string + target string + path string + validateFunc func(*osapi.Response[osapi.FileStatusResult], error) + }{ + { + name: "when checking file status returns result", + target: "_any", + path: "/etc/nginx/nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write( + []byte( + `{"job_id":"job-789","hostname":"web-01","path":"/etc/nginx/nginx.conf","status":"in-sync","sha256":"abc123"}`, + ), + ) + }, + validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) { + suite.NoError(err) + suite.NotNil(resp) + suite.Equal("job-789", resp.Data.JobID) + suite.Equal("web-01", resp.Data.Hostname) + suite.Equal("/etc/nginx/nginx.conf", resp.Data.Path) + suite.Equal("in-sync", resp.Data.Status) + suite.Equal("abc123", resp.Data.SHA256) + }, + }, + { + name: "when server returns 400 returns ValidationError", + target: "_any", + path: "", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"path is required"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusBadRequest, target.StatusCode) + }, + }, + { + name: "when server returns 403 returns AuthError", + target: "_any", + path: "/etc/nginx/nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"forbidden"}`)) + }, + validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.AuthError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusForbidden, target.StatusCode) + }, + }, + { + name: "when client HTTP call fails returns error", + target: "_any", + path: "/etc/nginx/nginx.conf", + serverURL: "http://127.0.0.1:0", + validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) { + suite.Error(err) + suite.Nil(resp) + suite.Contains(err.Error(), "file status") + }, + }, + { + name: "when server returns 200 with no JSON body returns UnexpectedStatusError", + target: "_any", + path: "/etc/nginx/nginx.conf", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) { + suite.Error(err) + suite.Nil(resp) + + var target *osapi.UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(http.StatusOK, target.StatusCode) + suite.Equal("nil response body", target.Message) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var ( + serverURL string + cleanup func() + ) + + if tc.serverURL != "" { + serverURL = tc.serverURL + cleanup = func() {} + } else { + server := httptest.NewServer(tc.handler) + serverURL = server.URL + cleanup = server.Close + } + defer cleanup() + + sut := osapi.New( + serverURL, + "test-token", + osapi.WithLogger(slog.Default()), + ) + + resp, err := sut.Node.FileStatus(suite.ctx, tc.target, tc.path) + tc.validateFunc(resp, err) + }) + } +} + +func TestNodePublicTestSuite(t *testing.T) { + suite.Run(t, new(NodePublicTestSuite)) +} diff --git a/pkg/sdk/osapi/node_types.go b/pkg/sdk/osapi/node_types.go new file mode 100644 index 00000000..b54f6390 --- /dev/null +++ b/pkg/sdk/osapi/node_types.go @@ -0,0 +1,501 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + openapi_types "github.com/oapi-codegen/runtime/types" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// Collection is a generic wrapper for collection responses from node queries. +type Collection[T any] struct { + Results []T + JobID string +} + +// Disk represents disk usage information. +type Disk struct { + Name string + Total int + Used int + Free int +} + +// HostnameResult represents a hostname query result from a single agent. +type HostnameResult struct { + Hostname string + Error string + Labels map[string]string +} + +// NodeStatus represents full node status from a single agent. +type NodeStatus struct { + Hostname string + Uptime string + Error string + Disks []Disk + LoadAverage *LoadAverage + Memory *Memory + OSInfo *OSInfo +} + +// DiskResult represents disk query result from a single agent. +type DiskResult struct { + Hostname string + Error string + Disks []Disk +} + +// MemoryResult represents memory query result from a single agent. +type MemoryResult struct { + Hostname string + Error string + Memory *Memory +} + +// LoadResult represents load average query result from a single agent. +type LoadResult struct { + Hostname string + Error string + LoadAverage *LoadAverage +} + +// OSInfoResult represents OS info query result from a single agent. +type OSInfoResult struct { + Hostname string + Error string + OSInfo *OSInfo +} + +// UptimeResult represents uptime query result from a single agent. +type UptimeResult struct { + Hostname string + Uptime string + Error string +} + +// DNSConfig represents DNS configuration from a single agent. +type DNSConfig struct { + Hostname string + Error string + Servers []string + SearchDomains []string +} + +// DNSUpdateResult represents DNS update result from a single agent. +type DNSUpdateResult struct { + Hostname string + Status string + Error string + Changed bool +} + +// PingResult represents ping result from a single agent. +type PingResult struct { + Hostname string + Error string + PacketsSent int + PacketsReceived int + PacketLoss float64 + MinRtt string + AvgRtt string + MaxRtt string +} + +// 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 +} + +// loadAverageFromGen converts a gen.LoadAverageResponse to a LoadAverage. +func loadAverageFromGen( + g *gen.LoadAverageResponse, +) *LoadAverage { + if g == nil { + return nil + } + + return &LoadAverage{ + OneMin: g.N1min, + FiveMin: g.N5min, + FifteenMin: g.N15min, + } +} + +// memoryFromGen converts a gen.MemoryResponse to a Memory. +func memoryFromGen( + g *gen.MemoryResponse, +) *Memory { + if g == nil { + return nil + } + + return &Memory{ + Total: g.Total, + Used: g.Used, + Free: g.Free, + } +} + +// osInfoFromGen converts a gen.OSInfoResponse to an OSInfo. +func osInfoFromGen( + g *gen.OSInfoResponse, +) *OSInfo { + if g == nil { + return nil + } + + return &OSInfo{ + Distribution: g.Distribution, + Version: g.Version, + } +} + +// disksFromGen converts a gen.DisksResponse to a slice of Disk. +func disksFromGen( + g *gen.DisksResponse, +) []Disk { + if g == nil { + return nil + } + + disks := make([]Disk, 0, len(*g)) + for _, d := range *g { + disks = append(disks, Disk{ + Name: d.Name, + Total: d.Total, + Used: d.Used, + Free: d.Free, + }) + } + + return disks +} + +// derefString safely dereferences a string pointer, returning empty string for nil. +func derefString( + s *string, +) string { + if s == nil { + return "" + } + + return *s +} + +// derefInt safely dereferences an int pointer, returning zero for nil. +func derefInt( + i *int, +) int { + if i == nil { + return 0 + } + + return *i +} + +// derefInt64 safely dereferences an int64 pointer, returning zero for nil. +func derefInt64( + i *int64, +) int64 { + if i == nil { + return 0 + } + + return *i +} + +// derefFloat64 safely dereferences a float64 pointer, returning zero for nil. +func derefFloat64( + f *float64, +) float64 { + if f == nil { + return 0 + } + + return *f +} + +// derefBool safely dereferences a bool pointer, returning false for nil. +func derefBool( + b *bool, +) bool { + if b == nil { + return false + } + + return *b +} + +// jobIDFromGen extracts a job ID string from an optional UUID pointer. +func jobIDFromGen( + id *openapi_types.UUID, +) string { + if id == nil { + return "" + } + + return id.String() +} + +// hostnameCollectionFromGen converts a gen.HostnameCollectionResponse to a Collection[HostnameResult]. +func hostnameCollectionFromGen( + g *gen.HostnameCollectionResponse, +) Collection[HostnameResult] { + results := make([]HostnameResult, 0, len(g.Results)) + for _, r := range g.Results { + hr := HostnameResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + } + + if r.Labels != nil { + hr.Labels = *r.Labels + } + + results = append(results, hr) + } + + return Collection[HostnameResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// nodeStatusCollectionFromGen converts a gen.NodeStatusCollectionResponse to a Collection[NodeStatus]. +func nodeStatusCollectionFromGen( + g *gen.NodeStatusCollectionResponse, +) Collection[NodeStatus] { + results := make([]NodeStatus, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, NodeStatus{ + Hostname: r.Hostname, + Uptime: derefString(r.Uptime), + Error: derefString(r.Error), + Disks: disksFromGen(r.Disks), + LoadAverage: loadAverageFromGen(r.LoadAverage), + Memory: memoryFromGen(r.Memory), + OSInfo: osInfoFromGen(r.OsInfo), + }) + } + + return Collection[NodeStatus]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// diskCollectionFromGen converts a gen.DiskCollectionResponse to a Collection[DiskResult]. +func diskCollectionFromGen( + g *gen.DiskCollectionResponse, +) Collection[DiskResult] { + results := make([]DiskResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, DiskResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + Disks: disksFromGen(r.Disks), + }) + } + + return Collection[DiskResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// memoryCollectionFromGen converts a gen.MemoryCollectionResponse to a Collection[MemoryResult]. +func memoryCollectionFromGen( + g *gen.MemoryCollectionResponse, +) Collection[MemoryResult] { + results := make([]MemoryResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, MemoryResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + Memory: memoryFromGen(r.Memory), + }) + } + + return Collection[MemoryResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// loadCollectionFromGen converts a gen.LoadCollectionResponse to a Collection[LoadResult]. +func loadCollectionFromGen( + g *gen.LoadCollectionResponse, +) Collection[LoadResult] { + results := make([]LoadResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, LoadResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + LoadAverage: loadAverageFromGen(r.LoadAverage), + }) + } + + return Collection[LoadResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// osInfoCollectionFromGen converts a gen.OSInfoCollectionResponse to a Collection[OSInfoResult]. +func osInfoCollectionFromGen( + g *gen.OSInfoCollectionResponse, +) Collection[OSInfoResult] { + results := make([]OSInfoResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, OSInfoResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + OSInfo: osInfoFromGen(r.OsInfo), + }) + } + + return Collection[OSInfoResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// uptimeCollectionFromGen converts a gen.UptimeCollectionResponse to a Collection[UptimeResult]. +func uptimeCollectionFromGen( + g *gen.UptimeCollectionResponse, +) Collection[UptimeResult] { + results := make([]UptimeResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, UptimeResult{ + Hostname: r.Hostname, + Uptime: derefString(r.Uptime), + Error: derefString(r.Error), + }) + } + + return Collection[UptimeResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// dnsConfigCollectionFromGen converts a gen.DNSConfigCollectionResponse to a Collection[DNSConfig]. +func dnsConfigCollectionFromGen( + g *gen.DNSConfigCollectionResponse, +) Collection[DNSConfig] { + results := make([]DNSConfig, 0, len(g.Results)) + for _, r := range g.Results { + dc := DNSConfig{ + Hostname: r.Hostname, + Error: derefString(r.Error), + } + + if r.Servers != nil { + dc.Servers = *r.Servers + } + + if r.SearchDomains != nil { + dc.SearchDomains = *r.SearchDomains + } + + results = append(results, dc) + } + + return Collection[DNSConfig]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// dnsUpdateCollectionFromGen converts a gen.DNSUpdateCollectionResponse to a Collection[DNSUpdateResult]. +func dnsUpdateCollectionFromGen( + g *gen.DNSUpdateCollectionResponse, +) Collection[DNSUpdateResult] { + results := make([]DNSUpdateResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, DNSUpdateResult{ + Hostname: r.Hostname, + Status: string(r.Status), + Error: derefString(r.Error), + Changed: derefBool(r.Changed), + }) + } + + return Collection[DNSUpdateResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// pingCollectionFromGen converts a gen.PingCollectionResponse to a Collection[PingResult]. +func pingCollectionFromGen( + g *gen.PingCollectionResponse, +) Collection[PingResult] { + results := make([]PingResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, PingResult{ + Hostname: r.Hostname, + Error: derefString(r.Error), + PacketsSent: derefInt(r.PacketsSent), + PacketsReceived: derefInt(r.PacketsReceived), + PacketLoss: derefFloat64(r.PacketLoss), + MinRtt: derefString(r.MinRtt), + AvgRtt: derefString(r.AvgRtt), + MaxRtt: derefString(r.MaxRtt), + }) + } + + return Collection[PingResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} + +// commandCollectionFromGen converts a gen.CommandResultCollectionResponse to a Collection[CommandResult]. +func commandCollectionFromGen( + g *gen.CommandResultCollectionResponse, +) Collection[CommandResult] { + results := make([]CommandResult, 0, len(g.Results)) + for _, r := range g.Results { + results = append(results, CommandResult{ + Hostname: r.Hostname, + Stdout: derefString(r.Stdout), + Stderr: derefString(r.Stderr), + Error: derefString(r.Error), + ExitCode: derefInt(r.ExitCode), + Changed: derefBool(r.Changed), + DurationMs: derefInt64(r.DurationMs), + }) + } + + return Collection[CommandResult]{ + Results: results, + JobID: jobIDFromGen(g.JobId), + } +} diff --git a/pkg/sdk/osapi/node_types_test.go b/pkg/sdk/osapi/node_types_test.go new file mode 100644 index 00000000..b884bf49 --- /dev/null +++ b/pkg/sdk/osapi/node_types_test.go @@ -0,0 +1,974 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type NodeTypesTestSuite struct { + suite.Suite +} + +func (suite *NodeTypesTestSuite) TestLoadAverageFromGen() { + tests := []struct { + name string + input *gen.LoadAverageResponse + validateFunc func(*LoadAverage) + }{ + { + name: "when populated", + input: &gen.LoadAverageResponse{ + N1min: 0.5, + N5min: 1.2, + N15min: 0.8, + }, + validateFunc: func(la *LoadAverage) { + suite.Require().NotNil(la) + suite.InDelta(0.5, float64(la.OneMin), 0.001) + suite.InDelta(1.2, float64(la.FiveMin), 0.001) + suite.InDelta(0.8, float64(la.FifteenMin), 0.001) + }, + }, + { + name: "when nil", + input: nil, + validateFunc: func(la *LoadAverage) { + suite.Nil(la) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := loadAverageFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestMemoryFromGen() { + tests := []struct { + name string + input *gen.MemoryResponse + validateFunc func(*Memory) + }{ + { + name: "when populated", + input: &gen.MemoryResponse{ + Total: 8589934592, + Used: 4294967296, + Free: 4294967296, + }, + validateFunc: func(m *Memory) { + suite.Require().NotNil(m) + suite.Equal(8589934592, m.Total) + suite.Equal(4294967296, m.Used) + suite.Equal(4294967296, m.Free) + }, + }, + { + name: "when nil", + input: nil, + validateFunc: func(m *Memory) { + suite.Nil(m) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := memoryFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestOSInfoFromGen() { + tests := []struct { + name string + input *gen.OSInfoResponse + validateFunc func(*OSInfo) + }{ + { + name: "when populated", + input: &gen.OSInfoResponse{ + Distribution: "Ubuntu", + Version: "22.04", + }, + validateFunc: func(oi *OSInfo) { + suite.Require().NotNil(oi) + suite.Equal("Ubuntu", oi.Distribution) + suite.Equal("22.04", oi.Version) + }, + }, + { + name: "when nil", + input: nil, + validateFunc: func(oi *OSInfo) { + suite.Nil(oi) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := osInfoFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDisksFromGen() { + tests := []struct { + name string + input *gen.DisksResponse + validateFunc func([]Disk) + }{ + { + name: "when populated", + input: func() *gen.DisksResponse { + d := gen.DisksResponse{ + { + Name: "/dev/sda1", + Total: 500000000000, + Used: 250000000000, + Free: 250000000000, + }, + { + Name: "/dev/sdb1", + Total: 1000000000000, + Used: 100000000000, + Free: 900000000000, + }, + } + + return &d + }(), + validateFunc: func(disks []Disk) { + suite.Require().Len(disks, 2) + suite.Equal("/dev/sda1", disks[0].Name) + suite.Equal(500000000000, disks[0].Total) + suite.Equal(250000000000, disks[0].Used) + suite.Equal(250000000000, disks[0].Free) + suite.Equal("/dev/sdb1", disks[1].Name) + }, + }, + { + name: "when nil", + input: nil, + validateFunc: func(disks []Disk) { + suite.Nil(disks) + }, + }, + { + name: "when empty", + input: func() *gen.DisksResponse { + d := gen.DisksResponse{} + + return &d + }(), + validateFunc: func(disks []Disk) { + suite.NotNil(disks) + suite.Empty(disks) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := disksFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestHostnameCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + + tests := []struct { + name string + input *gen.HostnameCollectionResponse + validateFunc func(Collection[HostnameResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.HostnameCollectionResponse { + labels := map[string]string{"group": "web", "env": "prod"} + errMsg := "timeout" + + return &gen.HostnameCollectionResponse{ + JobId: &testUUID, + Results: []gen.HostnameResponse{ + { + Hostname: "web-01", + Labels: &labels, + }, + { + Hostname: "web-02", + Error: &errMsg, + }, + }, + } + }(), + validateFunc: func(c Collection[HostnameResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 2) + + suite.Equal("web-01", c.Results[0].Hostname) + suite.Equal(map[string]string{"group": "web", "env": "prod"}, c.Results[0].Labels) + suite.Empty(c.Results[0].Error) + + suite.Equal("web-02", c.Results[1].Hostname) + suite.Equal("timeout", c.Results[1].Error) + suite.Nil(c.Results[1].Labels) + }, + }, + { + name: "when minimal", + input: &gen.HostnameCollectionResponse{ + Results: []gen.HostnameResponse{ + {Hostname: "minimal-host"}, + }, + }, + validateFunc: func(c Collection[HostnameResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + suite.Equal("minimal-host", c.Results[0].Hostname) + suite.Empty(c.Results[0].Error) + suite.Nil(c.Results[0].Labels) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := hostnameCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestNodeStatusCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + + tests := []struct { + name string + input *gen.NodeStatusCollectionResponse + validateFunc func(Collection[NodeStatus]) + }{ + { + name: "when all sub-types are populated", + input: func() *gen.NodeStatusCollectionResponse { + uptime := "5d 3h 22m" + disks := gen.DisksResponse{ + { + Name: "/dev/sda1", + Total: 500000000000, + Used: 250000000000, + Free: 250000000000, + }, + } + + return &gen.NodeStatusCollectionResponse{ + JobId: &testUUID, + Results: []gen.NodeStatusResponse{ + { + Hostname: "web-01", + Uptime: &uptime, + Disks: &disks, + LoadAverage: &gen.LoadAverageResponse{ + N1min: 0.5, + N5min: 1.2, + N15min: 0.8, + }, + Memory: &gen.MemoryResponse{ + Total: 8589934592, + Used: 4294967296, + Free: 4294967296, + }, + OsInfo: &gen.OSInfoResponse{ + Distribution: "Ubuntu", + Version: "22.04", + }, + }, + }, + } + }(), + validateFunc: func(c Collection[NodeStatus]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + ns := c.Results[0] + suite.Equal("web-01", ns.Hostname) + suite.Equal("5d 3h 22m", ns.Uptime) + suite.Empty(ns.Error) + + suite.Require().Len(ns.Disks, 1) + suite.Equal("/dev/sda1", ns.Disks[0].Name) + suite.Equal(500000000000, ns.Disks[0].Total) + + suite.Require().NotNil(ns.LoadAverage) + suite.InDelta(0.5, float64(ns.LoadAverage.OneMin), 0.001) + suite.InDelta(1.2, float64(ns.LoadAverage.FiveMin), 0.001) + suite.InDelta(0.8, float64(ns.LoadAverage.FifteenMin), 0.001) + + suite.Require().NotNil(ns.Memory) + suite.Equal(8589934592, ns.Memory.Total) + suite.Equal(4294967296, ns.Memory.Used) + suite.Equal(4294967296, ns.Memory.Free) + + suite.Require().NotNil(ns.OSInfo) + suite.Equal("Ubuntu", ns.OSInfo.Distribution) + suite.Equal("22.04", ns.OSInfo.Version) + }, + }, + { + name: "when minimal", + input: &gen.NodeStatusCollectionResponse{ + Results: []gen.NodeStatusResponse{ + {Hostname: "minimal-host"}, + }, + }, + validateFunc: func(c Collection[NodeStatus]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + + ns := c.Results[0] + suite.Equal("minimal-host", ns.Hostname) + suite.Empty(ns.Uptime) + suite.Empty(ns.Error) + suite.Nil(ns.Disks) + suite.Nil(ns.LoadAverage) + suite.Nil(ns.Memory) + suite.Nil(ns.OSInfo) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := nodeStatusCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDiskCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + + tests := []struct { + name string + input *gen.DiskCollectionResponse + validateFunc func(Collection[DiskResult]) + }{ + { + name: "when disks are populated", + input: func() *gen.DiskCollectionResponse { + disks := gen.DisksResponse{ + { + Name: "/dev/sda1", + Total: 500000000000, + Used: 250000000000, + Free: 250000000000, + }, + { + Name: "/dev/sdb1", + Total: 1000000000000, + Used: 100000000000, + Free: 900000000000, + }, + } + + return &gen.DiskCollectionResponse{ + JobId: &testUUID, + Results: []gen.DiskResultItem{ + { + Hostname: "web-01", + Disks: &disks, + }, + }, + } + }(), + validateFunc: func(c Collection[DiskResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + dr := c.Results[0] + suite.Equal("web-01", dr.Hostname) + suite.Empty(dr.Error) + suite.Require().Len(dr.Disks, 2) + suite.Equal("/dev/sda1", dr.Disks[0].Name) + suite.Equal(500000000000, dr.Disks[0].Total) + suite.Equal(250000000000, dr.Disks[0].Used) + suite.Equal(250000000000, dr.Disks[0].Free) + suite.Equal("/dev/sdb1", dr.Disks[1].Name) + }, + }, + { + name: "when empty", + input: &gen.DiskCollectionResponse{ + Results: []gen.DiskResultItem{ + {Hostname: "web-01"}, + }, + }, + validateFunc: func(c Collection[DiskResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + suite.Equal("web-01", c.Results[0].Hostname) + suite.Nil(c.Results[0].Disks) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := diskCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestCommandCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + + tests := []struct { + name string + input *gen.CommandResultCollectionResponse + validateFunc func(Collection[CommandResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.CommandResultCollectionResponse { + stdout := "hello world\n" + stderr := "warning: something\n" + exitCode := 0 + changed := true + durationMs := int64(150) + + return &gen.CommandResultCollectionResponse{ + JobId: &testUUID, + Results: []gen.CommandResultItem{ + { + Hostname: "web-01", + Stdout: &stdout, + Stderr: &stderr, + ExitCode: &exitCode, + Changed: &changed, + DurationMs: &durationMs, + }, + }, + } + }(), + validateFunc: func(c Collection[CommandResult]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + cr := c.Results[0] + suite.Equal("web-01", cr.Hostname) + suite.Equal("hello world\n", cr.Stdout) + suite.Equal("warning: something\n", cr.Stderr) + suite.Empty(cr.Error) + suite.Equal(0, cr.ExitCode) + suite.True(cr.Changed) + suite.Equal(int64(150), cr.DurationMs) + }, + }, + { + name: "when minimal with error", + input: func() *gen.CommandResultCollectionResponse { + errMsg := "command not found" + exitCode := 127 + + return &gen.CommandResultCollectionResponse{ + Results: []gen.CommandResultItem{ + { + Hostname: "web-01", + Error: &errMsg, + ExitCode: &exitCode, + }, + }, + } + }(), + validateFunc: func(c Collection[CommandResult]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + + cr := c.Results[0] + suite.Equal("web-01", cr.Hostname) + suite.Equal("command not found", cr.Error) + suite.Equal(127, cr.ExitCode) + suite.Empty(cr.Stdout) + suite.Empty(cr.Stderr) + suite.False(cr.Changed) + suite.Zero(cr.DurationMs) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := commandCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDNSConfigCollectionFromGen() { + testUUID := openapi_types.UUID{ + 0x55, + 0x0e, + 0x84, + 0x00, + 0xe2, + 0x9b, + 0x41, + 0xd4, + 0xa7, + 0x16, + 0x44, + 0x66, + 0x55, + 0x44, + 0x00, + 0x00, + } + + tests := []struct { + name string + input *gen.DNSConfigCollectionResponse + validateFunc func(Collection[DNSConfig]) + }{ + { + name: "when all fields are populated", + input: func() *gen.DNSConfigCollectionResponse { + servers := []string{"8.8.8.8", "8.8.4.4"} + searchDomains := []string{"example.com", "local"} + + return &gen.DNSConfigCollectionResponse{ + JobId: &testUUID, + Results: []gen.DNSConfigResponse{ + { + Hostname: "web-01", + Servers: &servers, + SearchDomains: &searchDomains, + }, + }, + } + }(), + validateFunc: func(c Collection[DNSConfig]) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", c.JobID) + suite.Require().Len(c.Results, 1) + + dc := c.Results[0] + suite.Equal("web-01", dc.Hostname) + suite.Empty(dc.Error) + suite.Equal([]string{"8.8.8.8", "8.8.4.4"}, dc.Servers) + suite.Equal([]string{"example.com", "local"}, dc.SearchDomains) + }, + }, + { + name: "when minimal", + input: &gen.DNSConfigCollectionResponse{ + Results: []gen.DNSConfigResponse{ + {Hostname: "web-01"}, + }, + }, + validateFunc: func(c Collection[DNSConfig]) { + suite.Empty(c.JobID) + suite.Require().Len(c.Results, 1) + suite.Equal("web-01", c.Results[0].Hostname) + suite.Nil(c.Results[0].Servers) + suite.Nil(c.Results[0].SearchDomains) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := dnsConfigCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDNSUpdateCollectionFromGen() { + tests := []struct { + name string + input *gen.DNSUpdateCollectionResponse + validateFunc func(Collection[DNSUpdateResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.DNSUpdateCollectionResponse { + changed := true + + return &gen.DNSUpdateCollectionResponse{ + Results: []gen.DNSUpdateResultItem{ + { + Hostname: "web-01", + Status: gen.DNSUpdateResultItemStatus("applied"), + Changed: &changed, + }, + }, + } + }(), + validateFunc: func(c Collection[DNSUpdateResult]) { + suite.Require().Len(c.Results, 1) + + dr := c.Results[0] + suite.Equal("web-01", dr.Hostname) + suite.Equal("applied", dr.Status) + suite.True(dr.Changed) + suite.Empty(dr.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := dnsUpdateCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestPingCollectionFromGen() { + tests := []struct { + name string + input *gen.PingCollectionResponse + validateFunc func(Collection[PingResult]) + }{ + { + name: "when all fields are populated", + input: func() *gen.PingCollectionResponse { + packetsSent := 5 + packetsReceived := 5 + packetLoss := 0.0 + minRtt := "1.234ms" + avgRtt := "2.345ms" + maxRtt := "3.456ms" + + return &gen.PingCollectionResponse{ + Results: []gen.PingResponse{ + { + Hostname: "web-01", + PacketsSent: &packetsSent, + PacketsReceived: &packetsReceived, + PacketLoss: &packetLoss, + MinRtt: &minRtt, + AvgRtt: &avgRtt, + MaxRtt: &maxRtt, + }, + }, + } + }(), + validateFunc: func(c Collection[PingResult]) { + suite.Require().Len(c.Results, 1) + + pr := c.Results[0] + suite.Equal("web-01", pr.Hostname) + suite.Equal(5, pr.PacketsSent) + suite.Equal(5, pr.PacketsReceived) + suite.InDelta(0.0, pr.PacketLoss, 0.001) + suite.Equal("1.234ms", pr.MinRtt) + suite.Equal("2.345ms", pr.AvgRtt) + suite.Equal("3.456ms", pr.MaxRtt) + suite.Empty(pr.Error) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + result := pingCollectionFromGen(tc.input) + tc.validateFunc(result) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDerefString() { + s := "hello" + + tests := []struct { + name string + input *string + validateFunc func(string) + }{ + { + name: "when pointer is non-nil", + input: &s, + validateFunc: func(result string) { + suite.Equal("hello", result) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result string) { + suite.Empty(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(derefString(tc.input)) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDerefInt() { + i := 42 + + tests := []struct { + name string + input *int + validateFunc func(int) + }{ + { + name: "when pointer is non-nil", + input: &i, + validateFunc: func(result int) { + suite.Equal(42, result) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result int) { + suite.Zero(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(derefInt(tc.input)) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDerefInt64() { + i := int64(42) + + tests := []struct { + name string + input *int64 + validateFunc func(int64) + }{ + { + name: "when pointer is non-nil", + input: &i, + validateFunc: func(result int64) { + suite.Equal(int64(42), result) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result int64) { + suite.Zero(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(derefInt64(tc.input)) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDerefFloat64() { + f := 3.14 + + tests := []struct { + name string + input *float64 + validateFunc func(float64) + }{ + { + name: "when pointer is non-nil", + input: &f, + validateFunc: func(result float64) { + suite.InDelta(3.14, result, 0.001) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result float64) { + suite.InDelta(0.0, result, 0.001) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(derefFloat64(tc.input)) + }) + } +} + +func (suite *NodeTypesTestSuite) TestDerefBool() { + b := true + + tests := []struct { + name string + input *bool + validateFunc func(bool) + }{ + { + name: "when pointer is non-nil", + input: &b, + validateFunc: func(result bool) { + suite.True(result) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result bool) { + suite.False(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(derefBool(tc.input)) + }) + } +} + +func (suite *NodeTypesTestSuite) TestJobIDFromGen() { + id := openapi_types.UUID{ + 0x55, 0x0e, 0x84, 0x00, + 0xe2, 0x9b, 0x41, 0xd4, + 0xa7, 0x16, 0x44, 0x66, + 0x55, 0x44, 0x00, 0x00, + } + + tests := []struct { + name string + input *openapi_types.UUID + validateFunc func(string) + }{ + { + name: "when pointer is non-nil", + input: &id, + validateFunc: func(result string) { + suite.Equal("550e8400-e29b-41d4-a716-446655440000", result) + }, + }, + { + name: "when pointer is nil", + input: nil, + validateFunc: func(result string) { + suite.Empty(result) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + tc.validateFunc(jobIDFromGen(tc.input)) + }) + } +} + +func TestNodeTypesTestSuite(t *testing.T) { + suite.Run(t, new(NodeTypesTestSuite)) +} diff --git a/pkg/sdk/osapi/osapi.go b/pkg/sdk/osapi/osapi.go new file mode 100644 index 00000000..54136f86 --- /dev/null +++ b/pkg/sdk/osapi/osapi.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +// Package osapi provides a Go SDK for the OSAPI REST API. +// +// Create a client with New() and use the domain-specific services +// to interact with the API: +// +// client := osapi.New("http://localhost:8080", "your-jwt-token") +// +// // Get hostname +// resp, err := client.Node.Hostname(ctx, "_any") +// +// // Execute a command +// resp, err := client.Node.Exec(ctx, osapi.ExecRequest{ +// Command: "uptime", +// Target: "_all", +// }) +package osapi + +import ( + "log/slog" + "net/http" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// Client is the top-level OSAPI SDK client. Use New() to create one. +type Client struct { + // Agent provides agent discovery and details operations. + Agent *AgentService + + // Node provides node management operations (hostname, status, disk, + // memory, load, OS, uptime, network DNS/ping, command exec/shell). + Node *NodeService + + // Job provides job queue operations (create, get, list, delete, retry). + Job *JobService + + // Health provides health check operations (liveness, readiness, status). + Health *HealthService + + // Audit provides audit log operations (list, get, export). + Audit *AuditService + + // Metrics provides Prometheus metrics access. + Metrics *MetricsService + + // File provides file management operations (upload, list, get, delete). + File *FileService + + httpClient *gen.ClientWithResponses + baseURL string + logger *slog.Logger + baseTransport http.RoundTripper +} + +// Option configures the Client. +type Option func(*Client) + +// WithLogger sets a custom logger. Defaults to slog.Default(). +func WithLogger( + logger *slog.Logger, +) Option { + return func(c *Client) { + c.logger = logger + } +} + +// WithHTTPTransport sets a custom base HTTP transport. +func WithHTTPTransport( + transport http.RoundTripper, +) Option { + return func(c *Client) { + c.baseTransport = transport + } +} + +// New creates an OSAPI SDK client. +func New( + baseURL string, + bearerToken string, + opts ...Option, +) *Client { + c := &Client{ + baseURL: baseURL, + logger: slog.Default(), + baseTransport: http.DefaultTransport, + } + + for _, opt := range opts { + opt(c) + } + + transport := &authTransport{ + base: c.baseTransport, + authHeader: "Bearer " + bearerToken, + logger: c.logger, + } + + hc := &http.Client{ + Transport: transport, + } + + // Error is unreachable: the only ClientOption passed (WithHTTPClient) cannot + // fail, and NewClientWithResponses only errors when a ClientOption does. + // Invalid URLs are caught later at HTTP call time with a clear parse error. + httpClient, _ := gen.NewClientWithResponses(baseURL, gen.WithHTTPClient(hc)) + + c.httpClient = httpClient + c.Agent = &AgentService{client: httpClient} + c.Node = &NodeService{client: httpClient} + c.Job = &JobService{client: httpClient} + c.Health = &HealthService{client: httpClient} + c.Audit = &AuditService{client: httpClient} + c.Metrics = &MetricsService{ + client: httpClient, + baseURL: baseURL, + } + c.File = &FileService{client: httpClient} + + return c +} diff --git a/pkg/sdk/osapi/osapi_public_test.go b/pkg/sdk/osapi/osapi_public_test.go new file mode 100644 index 00000000..f572c613 --- /dev/null +++ b/pkg/sdk/osapi/osapi_public_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type ClientPublicTestSuite struct { + suite.Suite + + server *httptest.Server +} + +func (suite *ClientPublicTestSuite) SetupTest() { + suite.server = httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + }), + ) +} + +func (suite *ClientPublicTestSuite) TearDownTest() { + suite.server.Close() +} + +func (suite *ClientPublicTestSuite) TestNew() { + tests := []struct { + name string + opts func() []osapi.Option + validateFunc func(*osapi.Client) + }{ + { + name: "when creating client returns all services", + opts: func() []osapi.Option { + return nil + }, + validateFunc: func(c *osapi.Client) { + suite.NotNil(c) + suite.NotNil(c.Node) + suite.NotNil(c.Job) + suite.NotNil(c.Health) + suite.NotNil(c.Audit) + suite.NotNil(c.Metrics) + suite.NotNil(c.File) + }, + }, + { + name: "when custom transport provided creates client", + opts: func() []osapi.Option { + return []osapi.Option{ + osapi.WithHTTPTransport(&http.Transport{}), + osapi.WithLogger(slog.Default()), + } + }, + validateFunc: func(c *osapi.Client) { + suite.NotNil(c) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + c := osapi.New(suite.server.URL, "test-token", tc.opts()...) + tc.validateFunc(c) + }) + } +} + +func TestClientPublicTestSuite( + t *testing.T, +) { + suite.Run(t, new(ClientPublicTestSuite)) +} diff --git a/pkg/sdk/osapi/response.go b/pkg/sdk/osapi/response.go new file mode 100644 index 00000000..b02f1423 --- /dev/null +++ b/pkg/sdk/osapi/response.go @@ -0,0 +1,95 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "fmt" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +// Response wraps a domain type with raw JSON for CLI --json mode. +type Response[T any] struct { + Data T + rawJSON []byte +} + +// NewResponse creates a Response with the given data and raw JSON body. +func NewResponse[T any]( + data T, + rawJSON []byte, +) *Response[T] { + return &Response[T]{ + Data: data, + rawJSON: rawJSON, + } +} + +// RawJSON returns the raw HTTP response body. +func (r *Response[T]) RawJSON() []byte { + return r.rawJSON +} + +// checkError inspects the HTTP status code and returns the appropriate +// typed error. For success codes (200, 201, 202, 204) it returns nil. +// The variadic responses are the parsed error body pointers from the +// generated response struct (e.g., resp.JSON400, resp.JSON401, etc.). +func checkError( + statusCode int, + responses ...*gen.ErrorResponse, +) error { + switch { + case statusCode >= 200 && statusCode < 300: + return nil + } + + msg := extractErrorMessage(statusCode, responses...) + + switch statusCode { + case 400: + return &ValidationError{APIError{StatusCode: statusCode, Message: msg}} + case 401, 403: + return &AuthError{APIError{StatusCode: statusCode, Message: msg}} + case 404: + return &NotFoundError{APIError{StatusCode: statusCode, Message: msg}} + case 409: + return &ConflictError{APIError{StatusCode: statusCode, Message: msg}} + case 500: + return &ServerError{APIError{StatusCode: statusCode, Message: msg}} + default: + return &UnexpectedStatusError{APIError{StatusCode: statusCode, Message: msg}} + } +} + +// extractErrorMessage finds the first non-nil error message from the +// response pointers, or falls back to a generic message. +func extractErrorMessage( + statusCode int, + responses ...*gen.ErrorResponse, +) string { + for _, r := range responses { + if r != nil && r.Error != nil { + return *r.Error + } + } + + return fmt.Sprintf("unexpected status %d", statusCode) +} diff --git a/pkg/sdk/osapi/response_public_test.go b/pkg/sdk/osapi/response_public_test.go new file mode 100644 index 00000000..b5ac9a64 --- /dev/null +++ b/pkg/sdk/osapi/response_public_test.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +type ResponsePublicTestSuite struct { + suite.Suite +} + +func (suite *ResponsePublicTestSuite) TestRawJSON() { + tests := []struct { + name string + rawJSON []byte + validateFunc func(*osapi.Response[string]) + }{ + { + name: "when RawJSON returns the raw bytes", + rawJSON: []byte(`{"hostname":"web-01"}`), + validateFunc: func(resp *osapi.Response[string]) { + suite.Equal( + []byte(`{"hostname":"web-01"}`), + resp.RawJSON(), + ) + }, + }, + { + name: "when RawJSON returns nil for empty response", + rawJSON: nil, + validateFunc: func(resp *osapi.Response[string]) { + suite.Nil(resp.RawJSON()) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + resp := osapi.NewResponse("test", tc.rawJSON) + tc.validateFunc(resp) + }) + } +} + +func (suite *ResponsePublicTestSuite) TestData() { + tests := []struct { + name string + data string + rawJSON []byte + validateFunc func(*osapi.Response[string]) + }{ + { + name: "when Data contains the domain type", + data: "web-01", + rawJSON: []byte(`{"hostname":"web-01"}`), + validateFunc: func(resp *osapi.Response[string]) { + suite.Equal("web-01", resp.Data) + }, + }, + { + name: "when Data contains an empty string", + data: "", + rawJSON: []byte(`{}`), + validateFunc: func(resp *osapi.Response[string]) { + suite.Empty(resp.Data) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + resp := osapi.NewResponse(tc.data, tc.rawJSON) + tc.validateFunc(resp) + }) + } +} + +func TestResponsePublicTestSuite(t *testing.T) { + suite.Run(t, new(ResponsePublicTestSuite)) +} diff --git a/pkg/sdk/osapi/response_test.go b/pkg/sdk/osapi/response_test.go new file mode 100644 index 00000000..182288be --- /dev/null +++ b/pkg/sdk/osapi/response_test.go @@ -0,0 +1,208 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/osapi/gen" +) + +type ResponseTestSuite struct { + suite.Suite +} + +func (suite *ResponseTestSuite) TestCheckError() { + tests := []struct { + name string + statusCode int + validateFunc func(error) + }{ + { + name: "when status is 200", + statusCode: 200, + validateFunc: func(err error) { + suite.NoError(err) + }, + }, + { + name: "when status is 201", + statusCode: 201, + validateFunc: func(err error) { + suite.NoError(err) + }, + }, + { + name: "when status is 202", + statusCode: 202, + validateFunc: func(err error) { + suite.NoError(err) + }, + }, + { + name: "when status is 204", + statusCode: 204, + validateFunc: func(err error) { + suite.NoError(err) + }, + }, + { + name: "when status is 400", + statusCode: 400, + validateFunc: func(err error) { + suite.Error(err) + var target *ValidationError + suite.True(errors.As(err, &target)) + suite.Equal(400, target.StatusCode) + }, + }, + { + name: "when status is 401", + statusCode: 401, + validateFunc: func(err error) { + suite.Error(err) + var target *AuthError + suite.True(errors.As(err, &target)) + suite.Equal(401, target.StatusCode) + }, + }, + { + name: "when status is 403", + statusCode: 403, + validateFunc: func(err error) { + suite.Error(err) + var target *AuthError + suite.True(errors.As(err, &target)) + suite.Equal(403, target.StatusCode) + }, + }, + { + name: "when status is 404", + statusCode: 404, + validateFunc: func(err error) { + suite.Error(err) + var target *NotFoundError + suite.True(errors.As(err, &target)) + suite.Equal(404, target.StatusCode) + }, + }, + { + name: "when status is 409", + statusCode: 409, + validateFunc: func(err error) { + suite.Error(err) + var target *ConflictError + suite.True(errors.As(err, &target)) + suite.Equal(409, target.StatusCode) + }, + }, + { + name: "when status is 500", + statusCode: 500, + validateFunc: func(err error) { + suite.Error(err) + var target *ServerError + suite.True(errors.As(err, &target)) + suite.Equal(500, target.StatusCode) + }, + }, + { + name: "when status is 503", + statusCode: 503, + validateFunc: func(err error) { + suite.Error(err) + var target *UnexpectedStatusError + suite.True(errors.As(err, &target)) + suite.Equal(503, target.StatusCode) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + err := checkError(tc.statusCode) + tc.validateFunc(err) + }) + } +} + +func (suite *ResponseTestSuite) TestCheckErrorMessages() { + tests := []struct { + name string + statusCode int + responses []*gen.ErrorResponse + validateFunc func(error) + }{ + { + name: "when error response contains a message", + statusCode: 400, + responses: func() []*gen.ErrorResponse { + msg := "field 'name' is required" + return []*gen.ErrorResponse{{Error: &msg}} + }(), + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "field 'name' is required") + }, + }, + { + name: "when all responses are nil", + statusCode: 400, + responses: []*gen.ErrorResponse{nil, nil}, + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "unexpected status 400") + }, + }, + { + name: "when no responses are provided", + statusCode: 500, + responses: nil, + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "unexpected status 500") + }, + }, + { + name: "when response has nil Error field", + statusCode: 404, + responses: []*gen.ErrorResponse{{Error: nil}}, + validateFunc: func(err error) { + suite.Error(err) + suite.Contains(err.Error(), "unexpected status 404") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + err := checkError(tc.statusCode, tc.responses...) + tc.validateFunc(err) + }) + } +} + +func TestResponseTestSuite(t *testing.T) { + suite.Run(t, new(ResponseTestSuite)) +} diff --git a/pkg/sdk/osapi/transport.go b/pkg/sdk/osapi/transport.go new file mode 100644 index 00000000..323a7f15 --- /dev/null +++ b/pkg/sdk/osapi/transport.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "log/slog" + "net/http" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +type authTransport struct { + base http.RoundTripper + authHeader string + logger *slog.Logger +} + +// RoundTrip implements the http.RoundTripper interface. +func (t *authTransport) RoundTrip( + req *http.Request, +) (*http.Response, error) { + req.Header.Set("Authorization", t.authHeader) + otel.GetTextMapPropagator().Inject(req.Context(), propagation.HeaderCarrier(req.Header)) + + start := time.Now() + resp, err := t.base.RoundTrip(req) + duration := time.Since(start) + + if err != nil { + t.logger.Debug("http request failed", + slog.String("method", req.Method), + slog.String("url", req.URL.String()), + slog.String("error", err.Error()), + slog.Duration("duration", duration), + ) + return nil, err + } + + t.logger.Debug("http response", + slog.String("method", req.Method), + slog.String("url", req.URL.String()), + slog.Int("status", resp.StatusCode), + slog.Duration("duration", duration), + ) + + return resp, nil +} diff --git a/pkg/sdk/osapi/transport_test.go b/pkg/sdk/osapi/transport_test.go new file mode 100644 index 00000000..c5e2cfb9 --- /dev/null +++ b/pkg/sdk/osapi/transport_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +import ( + "fmt" + "log/slog" + "net/http" + "testing" + + "github.com/stretchr/testify/suite" +) + +type failingRoundTripper struct{} + +func (f *failingRoundTripper) RoundTrip( + _ *http.Request, +) (*http.Response, error) { + return nil, fmt.Errorf("transport error") +} + +type TransportTestSuite struct { + suite.Suite +} + +func (s *TransportTestSuite) TestRoundTripError() { + tests := []struct { + name string + validateFunc func(*http.Response, error) + }{ + { + name: "when base transport fails returns error", + validateFunc: func(resp *http.Response, err error) { + s.Error(err) + s.Contains(err.Error(), "transport error") + s.Nil(resp) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + transport := &authTransport{ + base: &failingRoundTripper{}, + authHeader: "Bearer test-token", + logger: slog.Default(), + } + + req, err := http.NewRequest(http.MethodGet, "http://example.com/test", nil) + s.Require().NoError(err) + + resp, err := transport.RoundTrip(req) + tt.validateFunc(resp, err) + + s.Equal("Bearer test-token", req.Header.Get("Authorization")) + }) + } +} + +func TestTransportTestSuite(t *testing.T) { + suite.Run(t, new(TransportTestSuite)) +} diff --git a/pkg/sdk/osapi/types.go b/pkg/sdk/osapi/types.go new file mode 100644 index 00000000..eae7db17 --- /dev/null +++ b/pkg/sdk/osapi/types.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package osapi + +// 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 +} From b4faaff0e4f652857046c1d51e9edd79eb9b887e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 16:58:41 -0800 Subject: [PATCH 4/9] refactor(sdk): update imports to pkg/sdk/osapi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all imports of github.com/osapi-io/osapi-sdk/pkg/osapi with github.com/retr0h/osapi/pkg/sdk/osapi, pointing consumers at the in-repo SDK client library. Remove the external osapi-sdk dependency from go.mod and go.sum. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client.go | 2 +- cmd/client_agent_get.go | 2 +- cmd/client_audit_export.go | 2 +- cmd/client_file_upload.go | 2 +- cmd/client_health_status.go | 2 +- cmd/client_job_list.go | 2 +- cmd/client_job_run.go | 2 +- cmd/client_node_command_exec.go | 2 +- cmd/client_node_command_shell.go | 2 +- cmd/client_node_file_deploy.go | 2 +- cmd/client_node_status_get.go | 2 +- go.mod | 1 - go.sum | 4 ---- internal/audit/export/export_public_test.go | 2 +- internal/audit/export/file.go | 2 +- internal/audit/export/file_public_test.go | 2 +- internal/audit/export/file_test.go | 2 +- internal/audit/export/types.go | 2 +- internal/cli/ui.go | 2 +- internal/cli/ui_public_test.go | 2 +- 20 files changed, 18 insertions(+), 23 deletions(-) diff --git a/cmd/client.go b/cmd/client.go index 604e2aab..4c606e6b 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -24,7 +24,7 @@ import ( "context" "log/slog" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/spf13/viper" diff --git a/cmd/client_agent_get.go b/cmd/client_agent_get.go index b80ae80d..b0d3b216 100644 --- a/cmd/client_agent_get.go +++ b/cmd/client_agent_get.go @@ -25,7 +25,7 @@ import ( "strings" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_audit_export.go b/cmd/client_audit_export.go index b4389cb9..16b9efbf 100644 --- a/cmd/client_audit_export.go +++ b/cmd/client_audit_export.go @@ -25,7 +25,7 @@ import ( "fmt" "strconv" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/audit/export" diff --git a/cmd/client_file_upload.go b/cmd/client_file_upload.go index 5710a41d..aca03981 100644 --- a/cmd/client_file_upload.go +++ b/cmd/client_file_upload.go @@ -27,7 +27,7 @@ import ( "github.com/spf13/cobra" - osapi "github.com/osapi-io/osapi-sdk/pkg/osapi" + osapi "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/retr0h/osapi/internal/cli" ) diff --git a/cmd/client_health_status.go b/cmd/client_health_status.go index 33b2cd0e..3d8ac317 100644 --- a/cmd/client_health_status.go +++ b/cmd/client_health_status.go @@ -23,7 +23,7 @@ package cmd import ( "fmt" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_job_list.go b/cmd/client_job_list.go index aad8e4d7..e38d508f 100644 --- a/cmd/client_job_list.go +++ b/cmd/client_job_list.go @@ -26,7 +26,7 @@ import ( "strings" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_job_run.go b/cmd/client_job_run.go index b4650a8d..85279441 100644 --- a/cmd/client_job_run.go +++ b/cmd/client_job_run.go @@ -29,7 +29,7 @@ import ( "os" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_node_command_exec.go b/cmd/client_node_command_exec.go index bc632135..cf455c32 100644 --- a/cmd/client_node_command_exec.go +++ b/cmd/client_node_command_exec.go @@ -25,7 +25,7 @@ import ( "os" "strconv" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_node_command_shell.go b/cmd/client_node_command_shell.go index 91a9e504..1356edb9 100644 --- a/cmd/client_node_command_shell.go +++ b/cmd/client_node_command_shell.go @@ -25,7 +25,7 @@ import ( "os" "strconv" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_node_file_deploy.go b/cmd/client_node_file_deploy.go index e1cc5cbb..c825befc 100644 --- a/cmd/client_node_file_deploy.go +++ b/cmd/client_node_file_deploy.go @@ -24,7 +24,7 @@ import ( "fmt" "strings" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/cmd/client_node_status_get.go b/cmd/client_node_status_get.go index 72a97e7d..428c08fa 100644 --- a/cmd/client_node_status_get.go +++ b/cmd/client_node_status_get.go @@ -23,7 +23,7 @@ package cmd import ( "fmt" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/cli" diff --git a/go.mod b/go.mod index 73ebf8ab..a2a9fee2 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,6 @@ require ( github.com/oapi-codegen/runtime v1.2.0 github.com/osapi-io/nats-client v0.0.0-20260306210421-d68b2a0f287b github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 - github.com/osapi-io/osapi-sdk v0.0.0-20260307192743-857786ce1c9e github.com/prometheus-community/pro-bing v0.8.0 github.com/prometheus/client_golang v1.23.2 github.com/samber/slog-echo v1.21.0 diff --git a/go.sum b/go.sum index afd2e7ca..2fcfc63a 100644 --- a/go.sum +++ b/go.sum @@ -755,10 +755,6 @@ github.com/osapi-io/nats-client v0.0.0-20260306210421-d68b2a0f287b h1:d68ZLQLxJW github.com/osapi-io/nats-client v0.0.0-20260306210421-d68b2a0f287b/go.mod h1:66M9jRN03gZezKNttR17FCRZyLdF7E0BvBLitfrJl38= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848 h1:ELW1sTVBn5JIc17mHgd5fhpO3/7btaxJpxykG2Fe0U4= github.com/osapi-io/nats-server v0.0.0-20260216201410-1f33dfc63848/go.mod h1:4rzeY9jiJF/+Ej4WNwqK5HQ2sflZrEs60GxQpg3Iya8= -github.com/osapi-io/osapi-sdk v0.0.0-20260307073158-439e543a3013 h1:kcP1brAYrbrETk+8jgJKyGE8NI0zIvSg3hT5Y1oviT4= -github.com/osapi-io/osapi-sdk v0.0.0-20260307073158-439e543a3013/go.mod h1:i9g4jaIL6NVo9MRpz33lAEnY4L7u6aO97/5hN4W3hGE= -github.com/osapi-io/osapi-sdk v0.0.0-20260307192743-857786ce1c9e h1:sCg9f0Undm5zUdZ+oKESdplMhRvAlqmnMqKlyOoInX0= -github.com/osapi-io/osapi-sdk v0.0.0-20260307192743-857786ce1c9e/go.mod h1:i9g4jaIL6NVo9MRpz33lAEnY4L7u6aO97/5hN4W3hGE= github.com/otiai10/copy v1.2.0/go.mod h1:rrF5dJ5F0t/EWSYODDu4j9/vEeYHMkc8jt0zJChqQWw= github.com/otiai10/copy v1.14.0 h1:dCI/t1iTdYGtkvCuBG2BgR6KZa83PTclw4U5n2wAllU= github.com/otiai10/copy v1.14.0/go.mod h1:ECfuL02W+/FkTWZWgQqXPWZgW9oeKCSQ5qVfSc4qc4w= diff --git a/internal/audit/export/export_public_test.go b/internal/audit/export/export_public_test.go index c1c7b692..a91f6f1a 100644 --- a/internal/audit/export/export_public_test.go +++ b/internal/audit/export/export_public_test.go @@ -27,7 +27,7 @@ import ( "testing" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/audit/export" diff --git a/internal/audit/export/file.go b/internal/audit/export/file.go index d765c2ca..491ca5bc 100644 --- a/internal/audit/export/file.go +++ b/internal/audit/export/file.go @@ -28,7 +28,7 @@ import ( "io" "os" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" ) // marshalJSON is a package-level variable for testing the marshal error path. diff --git a/internal/audit/export/file_public_test.go b/internal/audit/export/file_public_test.go index 301d47e4..5bd1b423 100644 --- a/internal/audit/export/file_public_test.go +++ b/internal/audit/export/file_public_test.go @@ -31,7 +31,7 @@ import ( "testing" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/audit/export" diff --git a/internal/audit/export/file_test.go b/internal/audit/export/file_test.go index 0d21da4f..2cff9e24 100644 --- a/internal/audit/export/file_test.go +++ b/internal/audit/export/file_test.go @@ -28,7 +28,7 @@ import ( "testing" "time" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/stretchr/testify/suite" ) diff --git a/internal/audit/export/types.go b/internal/audit/export/types.go index cf7371c4..e6fc85c4 100644 --- a/internal/audit/export/types.go +++ b/internal/audit/export/types.go @@ -23,7 +23,7 @@ package export import ( "context" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" ) // Exporter writes audit entries to a backend. diff --git a/internal/cli/ui.go b/internal/cli/ui.go index 930bb833..ef2fafa9 100644 --- a/internal/cli/ui.go +++ b/internal/cli/ui.go @@ -31,7 +31,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/google/uuid" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" ) // Theme colors for terminal UI rendering. diff --git a/internal/cli/ui_public_test.go b/internal/cli/ui_public_test.go index 840d251e..c9020ee4 100644 --- a/internal/cli/ui_public_test.go +++ b/internal/cli/ui_public_test.go @@ -31,7 +31,7 @@ import ( "time" "github.com/google/uuid" - "github.com/osapi-io/osapi-sdk/pkg/osapi" + "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" From b8427a5265a27bbd4aa89fe5ddcefa44017a0810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:10:55 -0800 Subject: [PATCH 5/9] feat(sdk): add flattened client examples Add 9 SDK client examples (health, node, agent, audit, command, file, job, metrics, network) as standalone files in examples/sdk/osapi/ with their own go.mod. Each file uses //go:build ignore to allow individual execution via go run .go without conflicting main() functions. Co-Authored-By: Claude --- examples/sdk/osapi/agent.go | 116 +++++++++++++++++++++++++++ examples/sdk/osapi/audit.go | 82 +++++++++++++++++++ examples/sdk/osapi/command.go | 82 +++++++++++++++++++ examples/sdk/osapi/file.go | 144 ++++++++++++++++++++++++++++++++++ examples/sdk/osapi/go.mod | 20 +++++ examples/sdk/osapi/go.sum | 29 +++++++ examples/sdk/osapi/health.go | 77 ++++++++++++++++++ examples/sdk/osapi/job.go | 94 ++++++++++++++++++++++ examples/sdk/osapi/metrics.go | 58 ++++++++++++++ examples/sdk/osapi/network.go | 81 +++++++++++++++++++ examples/sdk/osapi/node.go | 144 ++++++++++++++++++++++++++++++++++ 11 files changed, 927 insertions(+) create mode 100644 examples/sdk/osapi/agent.go create mode 100644 examples/sdk/osapi/audit.go create mode 100644 examples/sdk/osapi/command.go create mode 100644 examples/sdk/osapi/file.go create mode 100644 examples/sdk/osapi/go.mod create mode 100644 examples/sdk/osapi/go.sum create mode 100644 examples/sdk/osapi/health.go create mode 100644 examples/sdk/osapi/job.go create mode 100644 examples/sdk/osapi/metrics.go create mode 100644 examples/sdk/osapi/network.go create mode 100644 examples/sdk/osapi/node.go diff --git a/examples/sdk/osapi/agent.go b/examples/sdk/osapi/agent.go new file mode 100644 index 00000000..0022f73d --- /dev/null +++ b/examples/sdk/osapi/agent.go @@ -0,0 +1,116 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the AgentService: listing the fleet and +// retrieving rich facts for a specific agent — OS info, load averages, +// memory stats, network interfaces, labels, and lifecycle timestamps. +// +// Run with: OSAPI_TOKEN="" go run agent.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + // List all active agents. + list, err := client.Agent.List(ctx) + if err != nil { + log.Fatalf("list agents: %v", err) + } + + fmt.Printf("Agents: %d total\n", list.Data.Total) + + for _, a := range list.Data.Agents { + fmt.Printf(" %s status=%s labels=%v\n", + a.Hostname, a.Status, a.Labels) + } + + if len(list.Data.Agents) == 0 { + return + } + + // Get rich facts for the first agent. + hostname := list.Data.Agents[0].Hostname + + resp, err := client.Agent.Get(ctx, hostname) + if err != nil { + log.Fatalf("get agent %s: %v", hostname, err) + } + + a := resp.Data + + fmt.Printf("\nAgent: %s\n", a.Hostname) + fmt.Printf(" Status: %s\n", a.Status) + fmt.Printf(" Architecture: %s\n", a.Architecture) + fmt.Printf(" Kernel: %s\n", a.KernelVersion) + fmt.Printf(" CPUs: %d\n", a.CPUCount) + fmt.Printf(" FQDN: %s\n", a.Fqdn) + fmt.Printf(" Package Mgr: %s\n", a.PackageMgr) + fmt.Printf(" Service Mgr: %s\n", a.ServiceMgr) + fmt.Printf(" Uptime: %s\n", a.Uptime) + fmt.Printf(" Started: %s\n", a.StartedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" Registered: %s\n", a.RegisteredAt.Format("2006-01-02 15:04:05")) + + if a.OSInfo != nil { + fmt.Printf(" OS: %s %s\n", + a.OSInfo.Distribution, a.OSInfo.Version) + } + + if a.LoadAverage != nil { + fmt.Printf(" Load: %.2f %.2f %.2f\n", + a.LoadAverage.OneMin, + a.LoadAverage.FiveMin, + a.LoadAverage.FifteenMin) + } + + if a.Memory != nil { + fmt.Printf(" Memory: total=%d used=%d free=%d\n", + a.Memory.Total, a.Memory.Used, a.Memory.Free) + } + + if len(a.Interfaces) > 0 { + fmt.Printf(" Interfaces:\n") + for _, iface := range a.Interfaces { + fmt.Printf(" %-12s ipv4=%-15s mac=%s\n", + iface.Name, iface.IPv4, iface.MAC) + } + } +} diff --git a/examples/sdk/osapi/audit.go b/examples/sdk/osapi/audit.go new file mode 100644 index 00000000..4b41347e --- /dev/null +++ b/examples/sdk/osapi/audit.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the AuditService: listing audit entries, +// retrieving a specific entry, and exporting all entries. +// +// Run with: OSAPI_TOKEN="" go run audit.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + // List recent audit entries. + list, err := client.Audit.List(ctx, 10, 0) + if err != nil { + log.Fatalf("list audit: %v", err) + } + + fmt.Printf("Audit entries: %d total\n", list.Data.TotalItems) + + for _, e := range list.Data.Items { + fmt.Printf(" %s %s %s code=%d user=%s\n", + e.ID, e.Method, e.Path, e.ResponseCode, e.User) + } + + if len(list.Data.Items) == 0 { + return + } + + // Get a specific audit entry. + id := list.Data.Items[0].ID + + entry, err := client.Audit.Get(ctx, id) + if err != nil { + log.Fatalf("get audit %s: %v", id, err) + } + + fmt.Printf("\nEntry %s:\n", entry.Data.ID) + fmt.Printf(" Method: %s\n", entry.Data.Method) + fmt.Printf(" Path: %s\n", entry.Data.Path) + fmt.Printf(" User: %s\n", entry.Data.User) + fmt.Printf(" Duration: %dms\n", entry.Data.DurationMs) +} diff --git a/examples/sdk/osapi/command.go b/examples/sdk/osapi/command.go new file mode 100644 index 00000000..7a2df56c --- /dev/null +++ b/examples/sdk/osapi/command.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates command execution: direct exec and +// shell-interpreted commands. +// +// Run with: OSAPI_TOKEN="" go run command.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + target := "_any" + + // Direct exec — runs a binary with arguments. + exec, err := client.Node.Exec(ctx, osapi.ExecRequest{ + Target: target, + Command: "uptime", + }) + if err != nil { + log.Fatalf("exec: %v", err) + } + + for _, r := range exec.Data.Results { + fmt.Printf("Exec (%s):\n", r.Hostname) + fmt.Printf(" stdout: %s\n", r.Stdout) + fmt.Printf(" exit: %d\n", r.ExitCode) + } + + // Shell — interpreted by /bin/sh, supports pipes and redirection. + shell, err := client.Node.Shell(ctx, osapi.ShellRequest{ + Target: target, + Command: "uname -a", + }) + if err != nil { + log.Fatalf("shell: %v", err) + } + + for _, r := range shell.Data.Results { + fmt.Printf("Shell (%s):\n", r.Hostname) + fmt.Printf(" stdout: %s\n", r.Stdout) + fmt.Printf(" exit: %d\n", r.ExitCode) + } +} diff --git a/examples/sdk/osapi/file.go b/examples/sdk/osapi/file.go new file mode 100644 index 00000000..67dba838 --- /dev/null +++ b/examples/sdk/osapi/file.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates file management: upload, check for changes, +// force upload, list, get metadata, deploy to an agent, check status, +// and delete. +// +// Run with: OSAPI_TOKEN="" go run file.go +package main + +import ( + "bytes" + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + // Upload a raw file to the Object Store. + content := []byte("listen_address = 0.0.0.0:8080\nworkers = 4\n") + upload, err := client.File.Upload( + ctx, + "app.conf", + "raw", + bytes.NewReader(content), + ) + if err != nil { + log.Fatalf("upload: %v", err) + } + + fmt.Printf("Uploaded: name=%s sha256=%s size=%d changed=%v\n", + upload.Data.Name, upload.Data.SHA256, upload.Data.Size, upload.Data.Changed) + + // Check if the file has changed without uploading. + chk, err := client.File.Changed(ctx, "app.conf", bytes.NewReader(content)) + if err != nil { + log.Fatalf("changed: %v", err) + } + + fmt.Printf("Changed: name=%s changed=%v\n", chk.Data.Name, chk.Data.Changed) + + // Force upload bypasses both SDK-side and server-side checks. + force, err := client.File.Upload( + ctx, + "app.conf", + "raw", + bytes.NewReader(content), + osapi.WithForce(), + ) + if err != nil { + log.Fatalf("force upload: %v", err) + } + + fmt.Printf("Force upload: name=%s changed=%v\n", + force.Data.Name, force.Data.Changed) + + // List all stored files. + list, err := client.File.List(ctx) + if err != nil { + log.Fatalf("list: %v", err) + } + + fmt.Printf("\nStored files (%d):\n", list.Data.Total) + for _, f := range list.Data.Files { + fmt.Printf(" %s size=%d\n", f.Name, f.Size) + } + + // Get metadata for a specific file. + meta, err := client.File.Get(ctx, "app.conf") + if err != nil { + log.Fatalf("get: %v", err) + } + + fmt.Printf("\nMetadata: name=%s sha256=%s size=%d\n", + meta.Data.Name, meta.Data.SHA256, meta.Data.Size) + + // Deploy the file to an agent. + deploy, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{ + ObjectName: "app.conf", + Path: "/tmp/app.conf", + ContentType: "raw", + Mode: "0644", + Target: "_any", + }) + if err != nil { + log.Fatalf("deploy: %v", err) + } + + fmt.Printf("\nDeploy: job=%s host=%s changed=%v\n", + deploy.Data.JobID, deploy.Data.Hostname, deploy.Data.Changed) + + // Check file status on the agent. + status, err := client.Node.FileStatus(ctx, "_any", "/tmp/app.conf") + if err != nil { + log.Fatalf("status: %v", err) + } + + fmt.Printf("Status: path=%s status=%s\n", + status.Data.Path, status.Data.Status) + + // Clean up — delete the file from the Object Store. + del, err := client.File.Delete(ctx, "app.conf") + if err != nil { + log.Fatalf("delete: %v", err) + } + + fmt.Printf("\nDeleted: name=%s deleted=%v\n", + del.Data.Name, del.Data.Deleted) +} diff --git a/examples/sdk/osapi/go.mod b/examples/sdk/osapi/go.mod new file mode 100644 index 00000000..ade9f14b --- /dev/null +++ b/examples/sdk/osapi/go.mod @@ -0,0 +1,20 @@ +module github.com/retr0h/osapi/examples/sdk/osapi + +go 1.25.0 + +replace github.com/retr0h/osapi => ../../../ + +require github.com/retr0h/osapi v0.0.0 + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/oapi-codegen/runtime v1.2.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.41.0 // indirect + go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.41.0 // indirect +) diff --git a/examples/sdk/osapi/go.sum b/examples/sdk/osapi/go.sum new file mode 100644 index 00000000..11fca53b --- /dev/null +++ b/examples/sdk/osapi/go.sum @@ -0,0 +1,29 @@ +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4= +github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= +go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= +go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= +go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= +go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= diff --git a/examples/sdk/osapi/health.go b/examples/sdk/osapi/health.go new file mode 100644 index 00000000..c43c0a64 --- /dev/null +++ b/examples/sdk/osapi/health.go @@ -0,0 +1,77 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the HealthService: liveness, readiness, +// and detailed system status checks. +// +// Run with: OSAPI_TOKEN="" go run health.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + // Liveness — is the API process running? + live, err := client.Health.Liveness(ctx) + if err != nil { + log.Fatalf("liveness: %v", err) + } + + fmt.Printf("Liveness: %s\n", live.Data.Status) + + // Readiness — is the API ready to serve requests? + ready, err := client.Health.Ready(ctx) + if err != nil { + log.Fatalf("readiness: %v", err) + } + + fmt.Printf("Readiness: %s\n", ready.Data.Status) + + // Status — detailed system info (requires auth). + status, err := client.Health.Status(ctx) + if err != nil { + log.Fatalf("status: %v", err) + } + + fmt.Printf("Status: %s\n", status.Data.Status) + fmt.Printf("Version: %s\n", status.Data.Version) + fmt.Printf("Uptime: %s\n", status.Data.Uptime) +} diff --git a/examples/sdk/osapi/job.go b/examples/sdk/osapi/job.go new file mode 100644 index 00000000..517821df --- /dev/null +++ b/examples/sdk/osapi/job.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the JobService: creating a job, polling +// for its result, listing jobs, and checking queue statistics. +// +// Run with: OSAPI_TOKEN="" go run job.go +package main + +import ( + "context" + "fmt" + "log" + "os" + "time" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + // Create a job. + created, err := client.Job.Create(ctx, map[string]any{ + "type": "node.hostname.get", + }, "_any") + if err != nil { + log.Fatalf("create job: %v", err) + } + + fmt.Printf("Created job: %s status=%s\n", + created.Data.JobID, created.Data.Status) + + // Poll until the job completes. + time.Sleep(2 * time.Second) + + job, err := client.Job.Get(ctx, created.Data.JobID) + if err != nil { + log.Fatalf("get job: %v", err) + } + + fmt.Printf("Job %s: status=%s\n", job.Data.ID, job.Data.Status) + + // List recent jobs. + list, err := client.Job.List(ctx, osapi.ListParams{Limit: 5}) + if err != nil { + log.Fatalf("list jobs: %v", err) + } + + fmt.Printf("\nRecent jobs: %d total\n", list.Data.TotalItems) + + for _, j := range list.Data.Items { + fmt.Printf(" %s status=%s op=%v\n", + j.ID, j.Status, j.Operation) + } + + // Queue statistics. + stats, err := client.Job.QueueStats(ctx) + if err != nil { + log.Fatalf("queue stats: %v", err) + } + + fmt.Printf("\nQueue: %d total jobs\n", stats.Data.TotalJobs) +} diff --git a/examples/sdk/osapi/metrics.go b/examples/sdk/osapi/metrics.go new file mode 100644 index 00000000..24d3bea8 --- /dev/null +++ b/examples/sdk/osapi/metrics.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the MetricsService: fetching raw +// Prometheus metrics text from the /metrics endpoint. +// +// Run with: OSAPI_TOKEN="" go run metrics.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + + text, err := client.Metrics.Get(ctx) + if err != nil { + log.Fatalf("metrics: %v", err) + } + + fmt.Println(text) +} diff --git a/examples/sdk/osapi/network.go b/examples/sdk/osapi/network.go new file mode 100644 index 00000000..e46ce0eb --- /dev/null +++ b/examples/sdk/osapi/network.go @@ -0,0 +1,81 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates network operations: reading DNS config +// and running a ping check. +// +// Run with: OSAPI_TOKEN="" go run network.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + iface := os.Getenv("OSAPI_INTERFACE") + if iface == "" { + iface = "eth0" + } + + client := osapi.New(url, token) + ctx := context.Background() + target := "_any" + + // Get DNS configuration for an interface. + dns, err := client.Node.GetDNS(ctx, target, iface) + if err != nil { + log.Fatalf("get dns: %v", err) + } + + for _, r := range dns.Data.Results { + fmt.Printf("DNS (%s):\n", r.Hostname) + fmt.Printf(" Servers: %v\n", r.Servers) + fmt.Printf(" Search: %v\n", r.SearchDomains) + } + + // Ping a host. + ping, err := client.Node.Ping(ctx, target, "8.8.8.8") + if err != nil { + log.Fatalf("ping: %v", err) + } + + for _, r := range ping.Data.Results { + fmt.Printf("Ping (%s):\n", r.Hostname) + fmt.Printf(" Sent=%d Received=%d Loss=%.1f%%\n", + r.PacketsSent, r.PacketsReceived, r.PacketLoss) + } +} diff --git a/examples/sdk/osapi/node.go b/examples/sdk/osapi/node.go new file mode 100644 index 00000000..2fd58012 --- /dev/null +++ b/examples/sdk/osapi/node.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +//go:build ignore + +// Package main demonstrates the NodeService: querying status, hostname, +// OS info, disk, memory, load averages, and uptime from a target node. +// +// Run with: OSAPI_TOKEN="" go run node.go +package main + +import ( + "context" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/osapi" +) + +func main() { + url := os.Getenv("OSAPI_URL") + if url == "" { + url = "http://localhost:8080" + } + + token := os.Getenv("OSAPI_TOKEN") + if token == "" { + log.Fatal("OSAPI_TOKEN is required") + } + + client := osapi.New(url, token) + ctx := context.Background() + target := "_any" + + // Status (aggregated node info). + status, err := client.Node.Status(ctx, target) + if err != nil { + log.Fatalf("status: %v", err) + } + + for _, r := range status.Data.Results { + fmt.Printf("Status (%s):\n", r.Hostname) + fmt.Printf(" Uptime: %s\n", r.Uptime) + + if r.OSInfo != nil { + fmt.Printf(" OS: %s %s\n", r.OSInfo.Distribution, r.OSInfo.Version) + } + + if r.LoadAverage != nil { + fmt.Printf(" Load: %.2f %.2f %.2f\n", + r.LoadAverage.OneMin, r.LoadAverage.FiveMin, r.LoadAverage.FifteenMin) + } + } + + // Hostname + hn, err := client.Node.Hostname(ctx, target) + if err != nil { + log.Fatalf("hostname: %v", err) + } + + for _, r := range hn.Data.Results { + fmt.Printf("Hostname: %s\n", r.Hostname) + } + + // Disk usage + disk, err := client.Node.Disk(ctx, target) + if err != nil { + log.Fatalf("disk: %v", err) + } + + for _, r := range disk.Data.Results { + fmt.Printf("Disk (%s):\n", r.Hostname) + for _, d := range r.Disks { + fmt.Printf(" %s total=%d used=%d free=%d\n", + d.Name, d.Total, d.Used, d.Free) + } + } + + // Memory + mem, err := client.Node.Memory(ctx, target) + if err != nil { + log.Fatalf("memory: %v", err) + } + + for _, r := range mem.Data.Results { + fmt.Printf("Memory (%s): total=%d free=%d\n", + r.Hostname, r.Memory.Total, r.Memory.Free) + } + + // Load averages + load, err := client.Node.Load(ctx, target) + if err != nil { + log.Fatalf("load: %v", err) + } + + for _, r := range load.Data.Results { + fmt.Printf("Load (%s): %.2f %.2f %.2f\n", + r.Hostname, + r.LoadAverage.OneMin, + r.LoadAverage.FiveMin, + r.LoadAverage.FifteenMin) + } + + // OS info + osInfo, err := client.Node.OS(ctx, target) + if err != nil { + log.Fatalf("os: %v", err) + } + + for _, r := range osInfo.Data.Results { + if r.OSInfo != nil { + fmt.Printf("OS (%s): %s %s\n", + r.Hostname, r.OSInfo.Distribution, r.OSInfo.Version) + } + } + + // Uptime + up, err := client.Node.Uptime(ctx, target) + if err != nil { + log.Fatalf("uptime: %v", err) + } + + for _, r := range up.Data.Results { + fmt.Printf("Uptime (%s): %s\n", r.Hostname, r.Uptime) + } +} From 732d2294c1b1608d160ec35788562e1de463ab3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:19:41 -0800 Subject: [PATCH 6/9] docs(sdk): add client library pages to Docusaurus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SDK section to docs site with landing page, client overview, and per-service reference pages (Agent, Audit, File, Health, Job, Metrics, Node). Update docusaurus.config.ts to add SDK navbar entry, point OpenAPI spec at internal api.yaml, and remove external SDK downloadUrl. Replace features _category_.json with a compact features.md table page. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- docs/docs/sidebar/features/_category_.json | 9 -- docs/docs/sidebar/features/features.md | 27 ++++ docs/docs/sidebar/sdk/client/agent.md | 34 +++++ docs/docs/sidebar/sdk/client/audit.md | 38 ++++++ docs/docs/sidebar/sdk/client/client.md | 52 ++++++++ docs/docs/sidebar/sdk/client/file.md | 145 +++++++++++++++++++++ docs/docs/sidebar/sdk/client/health.md | 39 ++++++ docs/docs/sidebar/sdk/client/job.md | 48 +++++++ docs/docs/sidebar/sdk/client/metrics.md | 30 +++++ docs/docs/sidebar/sdk/client/node.md | 109 ++++++++++++++++ docs/docs/sidebar/sdk/sdk.md | 11 ++ docs/docusaurus.config.ts | 56 +++++++- 12 files changed, 586 insertions(+), 12 deletions(-) delete mode 100644 docs/docs/sidebar/features/_category_.json create mode 100644 docs/docs/sidebar/features/features.md create mode 100644 docs/docs/sidebar/sdk/client/agent.md create mode 100644 docs/docs/sidebar/sdk/client/audit.md create mode 100644 docs/docs/sidebar/sdk/client/client.md create mode 100644 docs/docs/sidebar/sdk/client/file.md create mode 100644 docs/docs/sidebar/sdk/client/health.md create mode 100644 docs/docs/sidebar/sdk/client/job.md create mode 100644 docs/docs/sidebar/sdk/client/metrics.md create mode 100644 docs/docs/sidebar/sdk/client/node.md create mode 100644 docs/docs/sidebar/sdk/sdk.md diff --git a/docs/docs/sidebar/features/_category_.json b/docs/docs/sidebar/features/_category_.json deleted file mode 100644 index d4adb2ef..00000000 --- a/docs/docs/sidebar/features/_category_.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "label": "Features", - "position": 3, - "link": { - "type": "generated-index", - "title": "Features", - "description": "OSAPI provides a comprehensive set of features for managing Linux systems. Each feature page describes what it does, how it works, relevant configuration, and links to CLI and API documentation." - } -} diff --git a/docs/docs/sidebar/features/features.md b/docs/docs/sidebar/features/features.md new file mode 100644 index 00000000..f9dbeeca --- /dev/null +++ b/docs/docs/sidebar/features/features.md @@ -0,0 +1,27 @@ +--- +sidebar_position: 3 +--- + +# Features + +OSAPI provides a comprehensive set of features for managing Linux +systems. + + + +| | Feature | Description | +| --- | ---------------------------------------------- | --------------------------------------------------------------------------------------------- | +| 🖥️ | [Node Management](node-management.md) | Hostname, uptime, OS info, disk, memory, load | +| 🌐 | [Network Management](network-management.md) | DNS read/update, ping | +| ⚙️ | [Command Execution](command-execution.md) | Remote exec and shell across managed hosts | +| 📁 | [File Management](file-management.md) | Upload, deploy, and template files with SHA-based idempotency | +| 📊 | [System Facts](system-facts.md) | Agent-collected system facts -- architecture, kernel, FQDN, CPUs, network interfaces | +| 🔄 | [Agent Lifecycle](agent-lifecycle.md) | Node conditions, graceful drain/cordon for maintenance | +| ⚡ | [Job System](job-system.md) | NATS JetStream with KV-first architecture -- broadcast, load-balanced, and label-based routing | +| 💚 | [Health Checks](health-checks.md) | Liveness, readiness, system status endpoints | +| 📈 | [Metrics](metrics.md) | Prometheus `/metrics` endpoint | +| 📋 | [Audit Logging](audit-logging.md) | Structured API audit trail with 30-day retention | +| 🔐 | [Authentication & RBAC](authentication.md) | JWT with fine-grained `resource:verb` permissions | +| 🔍 | [Distributed Tracing](distributed-tracing.md) | OpenTelemetry with trace context propagation | + + diff --git a/docs/docs/sidebar/sdk/client/agent.md b/docs/docs/sidebar/sdk/client/agent.md new file mode 100644 index 00000000..b4e6581a --- /dev/null +++ b/docs/docs/sidebar/sdk/client/agent.md @@ -0,0 +1,34 @@ +--- +sidebar_position: 2 +--- + +# AgentService + +Agent discovery and details. + +## Methods + +| Method | Description | +| -------------------- | ----------------------------------- | +| `List(ctx)` | Retrieve all active agents | +| `Get(ctx, hostname)` | Get detailed agent info by hostname | + +## Usage + +```go +// List all agents +resp, err := client.Agent.List(ctx) + +// Get specific agent details +resp, err := client.Agent.Get(ctx, "web-01") +``` + +## Example + +See +[`examples/sdk/osapi/agent.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/agent.go) +for a complete working example. + +## Permissions + +Requires `agent:read` permission. diff --git a/docs/docs/sidebar/sdk/client/audit.md b/docs/docs/sidebar/sdk/client/audit.md new file mode 100644 index 00000000..3fef0a70 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/audit.md @@ -0,0 +1,38 @@ +--- +sidebar_position: 3 +--- + +# AuditService + +Audit log operations. + +## Methods + +| Method | Description | +| -------------------------- | -------------------------------- | +| `List(ctx, limit, offset)` | Retrieve entries with pagination | +| `Get(ctx, id)` | Retrieve a single entry by UUID | +| `Export(ctx)` | Retrieve all entries for export | + +## Usage + +```go +// List recent entries +resp, err := client.Audit.List(ctx, 20, 0) + +// Get a specific entry +resp, err := client.Audit.Get(ctx, "uuid-string") + +// Export all entries +resp, err := client.Audit.Export(ctx) +``` + +## Example + +See +[`examples/sdk/osapi/audit.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/audit.go) +for a complete working example. + +## Permissions + +Requires `audit:read` permission. diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md new file mode 100644 index 00000000..319db82c --- /dev/null +++ b/docs/docs/sidebar/sdk/client/client.md @@ -0,0 +1,52 @@ +--- +sidebar_position: 1 +--- + +# Client + +The `osapi` package provides a typed Go client for the OSAPI REST API. +Create a client with `New()` and use domain-specific services to interact +with the API. + +## Quick Start + +```go +import "github.com/retr0h/osapi/pkg/sdk/osapi" + +client := osapi.New("http://localhost:8080", "your-jwt-token") + +resp, err := client.Node.Hostname(ctx, "_any") +``` + +## Services + +| Service | Description | +| --------------------- | ---------------------------------- | +| [Agent](agent.md) | Agent discovery and details | +| [Audit](audit.md) | Audit log operations | +| [File](file.md) | File management (Object Store) | +| [Health](health.md) | Health check operations | +| [Job](job.md) | Async job queue operations | +| [Metrics](metrics.md) | Prometheus metrics access | +| [Node](node.md) | Node management, network, commands | + +## Client Options + +| Option | Description | +| ------------------------------ | --------------------------------- | +| `WithLogger(logger)` | Set custom `slog.Logger` | +| `WithHTTPTransport(transport)` | Set custom `http.RoundTripper` | + +`WithLogger` defaults to `slog.Default()`. `WithHTTPTransport` sets +the base transport for HTTP requests. + +## Targeting + +Most operations accept a `target` parameter: + +| Target | Behavior | +| ----------- | ------------------------------------------- | +| `_any` | Send to any available agent (load balanced) | +| `_all` | Broadcast to every agent | +| `hostname` | Send to a specific host | +| `key:value` | Send to agents matching a label | diff --git a/docs/docs/sidebar/sdk/client/file.md b/docs/docs/sidebar/sdk/client/file.md new file mode 100644 index 00000000..5583a8e1 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/file.md @@ -0,0 +1,145 @@ +--- +sidebar_position: 4 +--- + +# FileService + +File management operations for the OSAPI Object Store. Upload, list, +inspect, and delete files that can be deployed to agents via +`Node.FileDeploy`. + +## Methods + +### Object Store + +| Method | Description | +| ------------------------------- | ----------------------------------------------- | +| `Upload(ctx, name, ct, r, ...)` | Upload file content to Object Store | +| `Changed(ctx, name, r)` | Check if local content differs from stored file | +| `List(ctx)` | List all stored files | +| `Get(ctx, name)` | Get file metadata by name | +| `Delete(ctx, name)` | Delete a file from Object Store | + +### Node File Operations + +File deploy and status methods live on `NodeService` because they +target a specific host: + +| Method | Description | +| ------------------------------- | ----------------------------------- | +| `FileDeploy(ctx, opts)` | Deploy file to agent with SHA check | +| `FileStatus(ctx, target, path)` | Check deployed file status | + +## FileDeployOpts + +| Field | Type | Required | Description | +| ------------- | -------------- | -------- | ------------------------------------ | +| `ObjectName` | string | Yes | Name of the file in Object Store | +| `Path` | string | Yes | Destination path on the target host | +| `ContentType` | string | Yes | `"raw"` or `"template"` | +| `Mode` | string | No | File permission mode (e.g. `"0644"`) | +| `Owner` | string | No | File owner user | +| `Group` | string | No | File owner group | +| `Vars` | map[string]any | No | Template variables for `"template"` | +| `Target` | string | Yes | Host target (see Targeting below) | + +## Upload Options + +| Option | Description | +| ------------- | ------------------------------------------------------- | +| `WithForce()` | Bypass SDK-side and server-side SHA check; always write | + +## Usage + +```go +// Upload a raw file. +resp, err := client.File.Upload( + ctx, "nginx.conf", "raw", bytes.NewReader(data), +) + +// Force upload — skip SHA-256 check, always write. +resp, err := client.File.Upload( + ctx, "nginx.conf", "raw", bytes.NewReader(data), + osapi.WithForce(), +) + +// Check if content differs without uploading. +chk, err := client.File.Changed( + ctx, "nginx.conf", bytes.NewReader(data), +) +fmt.Println(chk.Data.Changed) // true if content differs + +// List all files. +resp, err := client.File.List(ctx) + +// Get file metadata. +resp, err := client.File.Get(ctx, "nginx.conf") + +// Delete a file. +resp, err := client.File.Delete(ctx, "nginx.conf") + +// Deploy a raw file to a specific host. +resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Mode: "0644", + Owner: "root", + Group: "root", + Target: "web-01", +}) + +// Deploy a template file with variables. +resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{ + ObjectName: "app.conf.tmpl", + Path: "/etc/app/config.yaml", + ContentType: "template", + Vars: map[string]any{ + "port": 8080, + "debug": false, + }, + Target: "_all", +}) + +// Check file status on a host. +resp, err := client.Node.FileStatus( + ctx, "web-01", "/etc/nginx/nginx.conf", +) +``` + +## Targeting + +`FileDeploy` and `FileStatus` accept any valid target: `_any`, `_all`, +a hostname, or a label selector (`key:value`). + +Object Store operations (`Upload`, `List`, `Get`, `Delete`) are +server-side and do not use targeting. + +## Change Detection + +`Upload` computes a SHA-256 of the file content locally before +uploading. If the hash matches the stored file, the upload is skipped +and `Changed: false` is returned. Use `WithForce()` to bypass this +check. + +`Changed` performs the same SHA-256 comparison without uploading. It +returns `Changed: true` when the file does not exist or the content +differs. + +## Idempotency + +`FileDeploy` compares the SHA-256 of the Object Store content against +the deployed file. If the hashes match, the file is not rewritten and +the response reports `Changed: false`. + +## Example + +See +[`examples/sdk/osapi/file.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/file.go) +for a complete working example. + +## Permissions + +Object Store operations require `file:read` (list, get) or `file:write` +(upload, delete). Deploy requires `file:write`. Status requires +`file:read`. diff --git a/docs/docs/sidebar/sdk/client/health.md b/docs/docs/sidebar/sdk/client/health.md new file mode 100644 index 00000000..286a2f47 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/health.md @@ -0,0 +1,39 @@ +--- +sidebar_position: 5 +--- + +# HealthService + +Health check operations. + +## Methods + +| Method | Description | +| --------------- | ------------------------------------------ | +| `Liveness(ctx)` | Check if API server process is alive | +| `Ready(ctx)` | Check if server and dependencies are ready | +| `Status(ctx)` | Detailed system status (components, NATS) | + +## Usage + +```go +// Simple liveness check (unauthenticated) +resp, err := client.Health.Liveness(ctx) + +// Readiness check (unauthenticated) +resp, err := client.Health.Ready(ctx) + +// Detailed status (requires auth) +resp, err := client.Health.Status(ctx) +``` + +## Example + +See +[`examples/sdk/osapi/health.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/health.go) +for a complete working example. + +## Permissions + +`Liveness` and `Ready` are unauthenticated. `Status` requires +`health:read` permission. diff --git a/docs/docs/sidebar/sdk/client/job.md b/docs/docs/sidebar/sdk/client/job.md new file mode 100644 index 00000000..6ee4dd15 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/job.md @@ -0,0 +1,48 @@ +--- +sidebar_position: 6 +--- + +# JobService + +Async job queue operations. + +## Methods + +| Method | Description | +| -------------------------------- | ------------------------------- | +| `Create(ctx, operation, target)` | Create a new job | +| `Get(ctx, id)` | Retrieve a job by UUID | +| `List(ctx, params)` | List jobs with optional filters | +| `Delete(ctx, id)` | Delete a job by UUID | +| `Retry(ctx, id, target)` | Retry a failed job | +| `QueueStats(ctx)` | Retrieve queue statistics | + +## Usage + +```go +// Create a job +resp, err := client.Job.Create(ctx, map[string]any{ + "type": "node.hostname.get", + "params": map[string]any{}, +}, "_any") + +// List completed jobs +resp, err := client.Job.List(ctx, osapi.ListParams{ + Status: "completed", + Limit: 20, +}) + +// Retry a failed job +resp, err := client.Job.Retry(ctx, "uuid-string", "_any") +``` + +## Example + +See +[`examples/sdk/osapi/job.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/job.go) +for a complete working example. + +## Permissions + +Read operations require `job:read`. Write operations (create, delete, +retry) require `job:write`. diff --git a/docs/docs/sidebar/sdk/client/metrics.md b/docs/docs/sidebar/sdk/client/metrics.md new file mode 100644 index 00000000..112d82c0 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/metrics.md @@ -0,0 +1,30 @@ +--- +sidebar_position: 7 +--- + +# MetricsService + +Prometheus metrics access. + +## Methods + +| Method | Description | +| ---------- | --------------------------------- | +| `Get(ctx)` | Fetch raw Prometheus metrics text | + +## Usage + +```go +text, err := client.Metrics.Get(ctx) +fmt.Print(text) +``` + +## Example + +See +[`examples/sdk/osapi/metrics.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/metrics.go) +for a complete working example. + +## Permissions + +Unauthenticated. The `/metrics` endpoint is open. diff --git a/docs/docs/sidebar/sdk/client/node.md b/docs/docs/sidebar/sdk/client/node.md new file mode 100644 index 00000000..b83a7927 --- /dev/null +++ b/docs/docs/sidebar/sdk/client/node.md @@ -0,0 +1,109 @@ +--- +sidebar_position: 8 +--- + +# NodeService + +Node management, network configuration, and command execution. This is +the largest service -- it combines node info, network, and command +operations that all target a specific host. + +## Methods + +### Node Info + +| Method | Description | +| ----------------------- | ----------------------------------------- | +| `Status(ctx, target)` | Full node status (OS, disk, memory, load) | +| `Hostname(ctx, target)` | Get system hostname | +| `Disk(ctx, target)` | Get disk usage | +| `Memory(ctx, target)` | Get memory statistics | +| `Load(ctx, target)` | Get load averages | +| `OS(ctx, target)` | Get operating system info | +| `Uptime(ctx, target)` | Get uptime | + +### Network + +| Method | Description | +| ------------------------------------------------ | ---------------------- | +| `GetDNS(ctx, target, iface)` | Get DNS config | +| `UpdateDNS(ctx, target, iface, servers, search)` | Update DNS servers | +| `Ping(ctx, target, address)` | Ping a host | + +### Command + +| Method | Description | +| ----------------- | ------------------------------------------- | +| `Exec(ctx, req)` | Execute a command directly (no shell) | +| `Shell(ctx, req)` | Execute via `/bin/sh -c` (pipes, redirects) | + +### File + +| Method | Description | +| ------------------------------- | ----------------------------------- | +| `FileDeploy(ctx, opts)` | Deploy file to agent with SHA check | +| `FileStatus(ctx, target, path)` | Check deployed file status | + +See [`FileService`](file.md) for Object Store operations (upload, list, +get, delete) and `FileDeployOpts` details. + +## Usage + +```go +// Get hostname +resp, err := client.Node.Hostname(ctx, "_any") + +// Get disk usage from all hosts +resp, err := client.Node.Disk(ctx, "_all") + +// Update DNS +resp, err := client.Node.UpdateDNS( + ctx, "web-01", "eth0", + []string{"8.8.8.8", "8.8.4.4"}, + nil, +) + +// Execute a command +resp, err := client.Node.Exec(ctx, osapi.ExecRequest{ + Command: "apt", + Args: []string{"install", "-y", "nginx"}, + Target: "_all", +}) + +// Execute a shell command +resp, err := client.Node.Shell(ctx, osapi.ShellRequest{ + Command: "ps aux | grep nginx", + Target: "_any", +}) + +// Deploy a file +resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{ + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Mode: "0644", + Target: "web-01", +}) + +// Check file status +resp, err := client.Node.FileStatus( + ctx, "web-01", "/etc/nginx/nginx.conf", +) +``` + +## Examples + +See +[`examples/sdk/osapi/node.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/node.go) +for node info, and +[`examples/sdk/osapi/network.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/network.go) +and +[`examples/sdk/osapi/command.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/command.go) +for network and command examples. + +## Permissions + +Node info requires `node:read`. Network read requires `network:read`. +DNS updates require `network:write`. Commands require +`command:execute`. File deploy requires `file:write`. File status +requires `file:read`. diff --git a/docs/docs/sidebar/sdk/sdk.md b/docs/docs/sidebar/sdk/sdk.md new file mode 100644 index 00000000..bbbed8fd --- /dev/null +++ b/docs/docs/sidebar/sdk/sdk.md @@ -0,0 +1,11 @@ +--- +sidebar_position: 6 +--- + +# SDK + +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. + + diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts index 47690288..4f253632 100644 --- a/docs/docusaurus.config.ts +++ b/docs/docusaurus.config.ts @@ -148,6 +148,58 @@ const config: Config = { position: 'left', docId: 'sidebar/usage/usage' }, + { + type: 'dropdown', + label: 'SDK', + position: 'left', + items: [ + { + type: 'doc', + label: 'Overview', + docId: 'sidebar/sdk/sdk' + }, + { + type: 'doc', + label: 'Client', + docId: 'sidebar/sdk/client/client' + }, + { + type: 'doc', + label: 'Agent', + docId: 'sidebar/sdk/client/agent' + }, + { + type: 'doc', + label: 'Audit', + docId: 'sidebar/sdk/client/audit' + }, + { + type: 'doc', + label: 'File', + docId: 'sidebar/sdk/client/file' + }, + { + type: 'doc', + label: 'Health', + docId: 'sidebar/sdk/client/health' + }, + { + type: 'doc', + label: 'Job', + docId: 'sidebar/sdk/client/job' + }, + { + type: 'doc', + label: 'Metrics', + docId: 'sidebar/sdk/client/metrics' + }, + { + type: 'doc', + label: 'Node', + docId: 'sidebar/sdk/client/node' + } + ] + }, { label: 'API', position: 'left', @@ -257,10 +309,8 @@ const config: Config = { docsPluginId: 'classic', config: { osapi: { - specPath: '../../osapi-sdk/pkg/osapi/gen/api.yaml', + specPath: '../internal/api/gen/api.yaml', outputDir: 'docs/gen/api', - downloadUrl: - 'https://github.com/osapi-io/osapi-sdk/blob/main/pkg/osapi/gen/api.yaml', sidebarOptions: { groupPathsBy: 'tag', categoryLinkSource: 'tag' From 5816bfd66bb988f8550fd675ec5c5c0568a8f105 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:22:43 -0800 Subject: [PATCH 7/9] docs: update CLAUDE.md for in-repo SDK Co-Authored-By: Claude --- CLAUDE.md | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c88029c2..84d9c6cc 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` -- **`osapi-sdk`** - External SDK for programmatic REST API access (sibling repo, linked via `replace` in `go.mod`) +- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`osapi/` client library, `orchestrator/` DAG runner) - 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. @@ -171,29 +171,25 @@ Create `internal/api/{domain}/`: ### Step 5: Update SDK -The `osapi-sdk` (sibling repo) provides the generated HTTP client used by -the CLI. The SDK syncs its `api.yaml` files from this repo via `gilt` -overlay (configured in `osapi-sdk/.gilt.yml`). When `just generate` runs -in the SDK, gilt pulls the latest specs from osapi's `main` branch and -regenerates the client code. +The SDK client library lives in `pkg/sdk/osapi/`. Its generated HTTP client +uses the same combined OpenAPI spec as the server +(`internal/api/gen/api.yaml`). -**When adding a new API domain:** +**When modifying existing API specs:** -1. Add the domain's `api.yaml` to `osapi-sdk/pkg/osapi/gen/{domain}/` -2. Run `just generate` in the SDK repo to regenerate the merged spec and - client code -3. Add a service wrapper in `osapi-sdk/pkg/osapi/{domain}.go` +1. Make changes to `internal/api/{domain}/gen/api.yaml` in this repo +2. Run `just generate` to regenerate server code (this also regenerates the + combined spec via `redocly join`) +3. Run `go generate ./pkg/sdk/osapi/gen/...` to regenerate the SDK client +4. Update the SDK service wrappers in `pkg/sdk/osapi/{domain}.go` if new + response codes were added +5. Update CLI switch blocks in `cmd/` if new response codes were added -**When modifying existing API specs** (adding responses, parameters, or -schemas to existing endpoints): +**When adding a new API domain:** -1. Make changes to `internal/api/{domain}/gen/api.yaml` in this repo -2. Run `just generate` here to regenerate server code -3. After merging to `main`, run `just generate` in `osapi-sdk` — gilt - will pull the updated specs and regenerate the client -4. Update the SDK service wrappers and CLI switch blocks if new response - codes were added (e.g., adding a 404 response requires a - `case http.StatusNotFound:` in the CLI) +1. Add a service wrapper in `pkg/sdk/osapi/{domain}.go` +2. Run `go generate ./pkg/sdk/osapi/gen/...` to pick up the new domain's + spec from the combined `api.yaml` ### Step 6: CLI Commands From a5918077717147cf909c9907947426eac1f3071e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:23:36 -0800 Subject: [PATCH 8/9] docs: update README and architecture for in-repo SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Features section from README (now in Docusaurus), simplify Documentation links, remove osapi-sdk from sister projects, and update system-architecture.md SDK references to pkg/sdk/osapi. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- README.md | 39 +------------------ .../architecture/system-architecture.md | 4 +- 2 files changed, 4 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 957109bf..bf3c77a1 100644 --- a/README.md +++ b/README.md @@ -22,56 +22,23 @@ them to be used as appliances. OSAPI demo -## ✨ Features - -| | | -|---|---| -| 🖥️ **[Node Management][]** | Hostname, uptime, OS info, disk, memory, load | -| 🌐 **[Network Management][]** | DNS read/update, ping | -| ⚙️ **[Command Execution][]** | Remote exec and shell across managed hosts | -| 📁 **[File Management][]** | Upload, deploy, and template files with SHA-based idempotency | -| 📊 **[System Facts][]** | Agent-collected system facts — architecture, kernel, FQDN, CPUs, network interfaces, service/package manager | -| 🔄 **[Agent Lifecycle][]** | Node conditions (memory, disk, load pressure), graceful drain/cordon for maintenance | -| ⚡ **[Async Job System][]** | NATS JetStream with KV-first architecture — broadcast, load-balanced, and label-based routing across hosts | -| 💚 **[Health][] & [Metrics][]** | Liveness, readiness, system status endpoints, Prometheus `/metrics` | -| 📋 **[Audit Logging][]** | Structured API audit trail in NATS KV with 30-day retention and admin-only read access | -| 🔐 **[Auth & RBAC][]** | JWT with fine-grained `resource:verb` permissions, built-in and custom roles, direct permission grants | -| 🔍 **[Distributed Tracing][]** | OpenTelemetry with trace context propagation across HTTP and NATS | -| 🖥️ **CLI Parity** | Every API operation has a CLI equivalent with `--json` for scripting | -| 🏢 **Multi-Tenant** | Namespace isolation lets multiple deployments share a single NATS cluster | - -[Node Management]: https://osapi-io.github.io/osapi/sidebar/features/node-management -[Network Management]: https://osapi-io.github.io/osapi/sidebar/features/network-management -[Command Execution]: https://osapi-io.github.io/osapi/sidebar/features/command-execution -[File Management]: https://osapi-io.github.io/osapi/sidebar/features/file-management -[Async Job System]: https://osapi-io.github.io/osapi/sidebar/features/job-system -[Health]: https://osapi-io.github.io/osapi/sidebar/features/health-checks -[Metrics]: https://osapi-io.github.io/osapi/sidebar/features/metrics -[Audit Logging]: https://osapi-io.github.io/osapi/sidebar/features/audit-logging -[Auth & RBAC]: https://osapi-io.github.io/osapi/sidebar/features/authentication -[Distributed Tracing]: https://osapi-io.github.io/osapi/sidebar/features/distributed-tracing - ## 📖 Documentation -[Features][] | [Architecture][] | [Getting Started][] | [API][] | [Usage][] | [Roadmap][] +[Getting Started][] | [API][] | [Usage][] | [SDK][] -[Features]: https://osapi-io.github.io/osapi/category/features -[Architecture]: https://osapi-io.github.io/osapi/sidebar/architecture [Getting Started]: https://osapi-io.github.io/osapi/ [API]: https://osapi-io.github.io/osapi/category/api [Usage]: https://osapi-io.github.io/osapi/sidebar/usage -[Roadmap]: https://osapi-io.github.io/osapi/sidebar/development/roadmap +[SDK]: https://osapi-io.github.io/osapi/sidebar/sdk/sdk ## 🔗 Sister Projects | Project | Description | | --- | --- | -| [osapi-sdk][] | Go SDK for OSAPI — client library and orchestration primitives | | [osapi-orchestrator][] | A Go package for orchestrating operations across OSAPI-managed hosts — typed operations, chaining, conditions, and result decoding built on top of the osapi-sdk engine | | [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-sdk]: https://github.com/osapi-io/osapi-sdk [osapi-orchestrator]: https://github.com/osapi-io/osapi-orchestrator [nats-client]: https://github.com/osapi-io/nats-client [nats-server]: https://github.com/osapi-io/nats-server @@ -80,6 +47,4 @@ them to be used as appliances. The [MIT][] License. -[Agent Lifecycle]: https://osapi-io.github.io/osapi/sidebar/features/agent-lifecycle -[System Facts]: https://osapi-io.github.io/osapi/sidebar/features/node-management [MIT]: LICENSE diff --git a/docs/docs/sidebar/architecture/system-architecture.md b/docs/docs/sidebar/architecture/system-architecture.md index d41b05e3..d4ee89c9 100644 --- a/docs/docs/sidebar/architecture/system-architecture.md +++ b/docs/docs/sidebar/architecture/system-architecture.md @@ -16,7 +16,7 @@ The system is organized into six layers, top to bottom: | Layer | Package | Role | | -------------------------- | --------------------------------------- | ------------------------------------------------------------------------ | | **CLI** | `cmd/` | Cobra command tree (thin wiring) | -| **SDK Client** | `osapi-sdk` (external) | OpenAPI-generated client used by CLI | +| **SDK Client** | `pkg/sdk/osapi` | OpenAPI-generated client used by CLI | | **REST API** | `internal/api/` | Echo server with JWT middleware | | **Job Client** | `internal/job/client/` | Business logic for job CRUD and status | | **NATS JetStream** | (external) | KV `job-queue`, Stream `JOBS`, KV `job-responses`, KV `agent-facts` | @@ -24,7 +24,7 @@ The system is organized into six layers, top to bottom: ```mermaid graph TD - CLI["CLI (cmd/)"] --> SDK["SDK Client (osapi-sdk)"] + CLI["CLI (cmd/)"] --> SDK["SDK Client (pkg/sdk/osapi)"] SDK --> API["REST API (internal/api/)"] API --> JobClient["Job Client (internal/job/client/)"] JobClient --> NATS["NATS JetStream"] From 7e24590aa0f0b4a09535036b0ce21fa620d8168b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:27:04 -0800 Subject: [PATCH 9/9] chore: apply linter formatting and regenerate API docs Prettier reformatted SDK client pages and plan docs. Docusaurus regenerated API docs from new specPath (internal/api/gen/api.yaml instead of external SDK). Co-Authored-By: Claude --- cmd/client_file_upload.go | 2 +- .../gen/api/agent-management-api.info.mdx | 7 -- docs/docs/gen/api/drain-agent.api.mdx | 95 ++++++++++++++++++- docs/docs/gen/api/get-agent-details.api.mdx | 95 ++++++++++++++++++- docs/docs/gen/api/list-jobs.api.mdx | 6 +- docs/docs/gen/api/post-file.api.mdx | 4 +- docs/docs/gen/api/undrain-agent.api.mdx | 95 ++++++++++++++++++- docs/docs/sidebar/features/features.md | 3 +- docs/docs/sidebar/sdk/client/client.md | 17 ++-- docs/docs/sidebar/sdk/client/file.md | 44 ++++----- docs/docs/sidebar/sdk/client/health.md | 4 +- docs/docs/sidebar/sdk/client/job.md | 4 +- docs/docs/sidebar/sdk/client/node.md | 27 +++--- ...026-03-07-sdk-monorepo-migration-design.md | 9 +- .../2026-03-07-sdk-monorepo-migration.md | 95 +++++++++---------- go.mod | 2 +- go.sum | 2 + 17 files changed, 383 insertions(+), 128 deletions(-) diff --git a/cmd/client_file_upload.go b/cmd/client_file_upload.go index aca03981..16393ab9 100644 --- a/cmd/client_file_upload.go +++ b/cmd/client_file_upload.go @@ -27,8 +27,8 @@ import ( "github.com/spf13/cobra" - osapi "github.com/retr0h/osapi/pkg/sdk/osapi" "github.com/retr0h/osapi/internal/cli" + osapi "github.com/retr0h/osapi/pkg/sdk/osapi" ) // clientFileUploadCmd represents the clientFileUpload command. diff --git a/docs/docs/gen/api/agent-management-api.info.mdx b/docs/docs/gen/api/agent-management-api.info.mdx index 866fd437..fb10598f 100644 --- a/docs/docs/gen/api/agent-management-api.info.mdx +++ b/docs/docs/gen/api/agent-management-api.info.mdx @@ -20,13 +20,6 @@ import Export from "@theme/ApiExplorer/Export"; > - - - - @@ -155,6 +155,97 @@ Stop the agent from accepting new jobs. In-flight jobs continue to completion. +
+ + + Invalid hostname. + + +
+ + + + +
+ + + Schema + +
+ +
    + + + + + + + +
+
+
+ + + + +
+
+
+
+
diff --git a/docs/docs/gen/api/get-agent-details.api.mdx b/docs/docs/gen/api/get-agent-details.api.mdx index 7aa73a3a..5e78940f 100644 --- a/docs/docs/gen/api/get-agent-details.api.mdx +++ b/docs/docs/gen/api/get-agent-details.api.mdx @@ -5,7 +5,7 @@ description: "Get detailed information about a specific agent by hostname." sidebar_label: "Get agent details" hide_title: true hide_table_of_contents: true -api: eJztWV9v2zgS/yoEn24BxZGSOJvqLZsmvdzttkGaYB+KwKDFsc2NRKrkKK3P8Hc/DCnLkq0kWmT3YYEiD7HN4W/+kzPDFZfgMqtKVEbzlH8AZBJQqBwkU3pmbCFoiYmpqZAJ5krI1ExlTMxBI5su2cI41KKAEY+4KcF6+mvJUz4HPCeq9x7Q8YijmDuefuH+58lvQos5FPTx/OZ64hEnDYTjDxF3kFVW4ZKnX1b8FxAW7HmFC8Lw5KkFIfnD+iHipbCiAATrPDGJxFO+kY5HXJGCpcAFj7iFr5WyIHmKtoKIu2wBheDpiuOypH0OrdJzvo52DHS3gEZjZmYMF1CbAg2zgFbBE4w4CWTBlUY7cAR7FMf0rwvmzVDb25H9MqMRNBKlKMtcZd4Sh384Il/tS2mmf0CGPOKlJbuhCswapff0GazOiFR3KLByfSigq4KccAtCLnnEPxoMHx/6OGSVtaRpwNvnk4sp5K5PLyGlIiCR33Q0fMVL/4XlwZPIK2ABmmVGz9S8siCZ0TvcLcyVQ7AgJwL7lA1ZwFMuBcIBKh9NXYa/L6AFy3LhkFmYWXALSiR0bAHC4hREY1mLfyHD0poMnGM1rudh3IQyuM+sXaxPIeX0nLmlQyjaiT/aCy6pSM5pFTYPCbB2cP2qdPWdtTGIA3wXRZkTzP200liR+E9g3WAWNfFQLkfxKD7h63X7IPjS1WwrwEPEUaHf9+nztZ6Z2zqxScqq9O4ZJGRt3bAlBL4RciKewIo5vO6nFgZtZPVGx2bGsiRi44gJLVkyZoXSFYLbd15SqLZFdVVMwe4x+rWF7sHJpj6mkxq6Y854dHy0jvj4jdgdsVvgR2friCdvRU+ehR/vxoE3Uq3PhnMrCIjHeWDRjoQCCmOXr3vxN0/HKkcivphpaFDkLUClEeY9St8RHQv8mdJsutzV8uz47Ow0JjvOLMAAyCsL8CLiUfzu52RMbq8cWe1VxHsH8kXEk+TdyXF8suuMYIRa8JpbyxnBmm0/CJstFEKGlR2Qlxc396y9o3tOiEKenhDoI1gN+WTwmfTpMwtbNidTF3c8Ssaj+OBdcjAHDVZlxCMrq0lmKo0DrPnRhz8dd7mZq0zk7OLmfsee5O2vcoCwV1WeL9nXSuRqpkAyaQqhNNsUdVuxv8H0IE5G9Q+jzBT+LgP7pDKYFHP7Oq9rrbA+xLrY4TdJgKXIHsV8IOBNIGaFLybtjv9KJECyop2JrFM7CGsFlS4KoeitPbrJ+Fw9tWUGuIg9t/Lp5GXK5N3RKDk9GyWjZLPj9OUdMziL0zTx54zIXqaN4zRJ0qOj9Pg4PTlJx2PaNROFypcD/HPDhJSWaomwpWtQWflk3JR/SgP6yhrwlKBo9WE3g73lWin7EfCbsY/XG6dsk5d8b1Uh7HLSuOx1kT+2yotmG6ODorkBJMxElSOzZvfuarzml/5UfOzU8kwHvTwTKqZQTHNgoNEue6oocKi0GFZEvd8SN1xqL3WViUf+j/SZC4RvYoDHPwTCfsBWpHYSaYBXajm3Htk/Tza2L4R7HI5I1HSFXFy/v2XaoNiv7w4DLLVj2YDT9JZ8zwJ5ByiJY8qd3Letr8kXUDzxjlfi+Hi/2GwFwNZbbRO3UsZDty+5fsvsR56iS9anf4Z/osEKTXGXyeV3BC0pqQiLzawp2HY3dSBPSoJ1TefYf1zuNI7vrVA6LF0YK40O13tfn0zdr6xy36cQvGeUGR1EeMPRHlaflzXUGDeUHVRTRPzfar6gKpDkV+6xWXno65mnxuQgdOg0hevLd98CO5ygFdp5bSbPdRU9reFexURbGkGegW6fx0bCxcaM+8F1SY20QJBMGwlsa3BvfwLLlYY3WF8V4FAU5VB1Iw5PoHsa53X0wuTDnwfOdRut7RpYa3qqjT3bNsJupGgZ8q62xaVf2DNkHcUUumzrDrZQDg1dEbu8WqOr2pctXh6MmlG+XtPGkzjZny/da1Hhwlj1P5DsgJ3fXLNHWLKGyV82cHrGevt3ZOv75sr2exkuBDKT+UmR7B6fV2Ea2Zqv1YOkUbBxGC6+znx7VtV76qFmI0QvW1kBsd5cuxQApsL67JFDeqm7Rkna0GEyjuP1euvUS6JqlUPBscf7jr0ydqqkBM0O2LV21WymMuXnQGAL5Zw/D39495/g3ZPnxsLaIJuZSv9I03+CI8d9831PuDEH1S3ibxr4/3Ds3+TY0EUsTP2cRYanB6SUH3pXHq42l/Sah0FIeH5qvVx9JhcGL7XfrxqpF4hUTHhP+3LRE/Go/nC1KYD+8/udrxCakX67EGDbpzS65VvD65Qnm46wNA4LobfDDP/a1wnJXduttgH61qfBWl2E73hY5kL5SrOyfr4ZbFo/6fGIp03p8xBKOlpcrabCwb3N12v6+WsFNG0lUz8Jq6jj8e9+Ujn6LHk6E7nb7WLaCv3rtq6DfmIDX/We0WJT92qqev2zE085p6Hhsv0Cuab2YAFCgvWShuXzLIMSWxv3zgF6S2yC8MPlHXVt3RjaiRmP3ivUahUo7swj6PW6kRHpOwm4Xv8fAFNaBg== +api: eJztWd9v2zgS/lcIPt0BsiM5djYVsA+5NOn5rtsGaYJ9KAKDFsc2NxKpkpQbn6H//TCkLEu2kmiR3YcCRR5im8NvfnxDcobcUg4m0SK3Qkka0w9gCQfLRAqcCLlQOmM4RNhcFZYwYnJIxEIkhC1BWjLfkJUyVrIMhjSgKgft5KecxnQJ9gKl3jtAQwNq2dLQ+Ct1P89+Y5ItIcOPFzfTmUOc1RCGPgTUQFJoYTc0/rql/wKmQV8UdoUYTjzWwDh9KB8CmjPNMrCgjRNGk2hMd9bRgAp0MGd2RQOq4VshNHAaW11AQJ8GiuVikCgOS5ADeLKaDby1W7pmqeDMItxuXpAJ+WsUZOzp19FkQsuAmmQFGUNxu8lR1Fgt5JIGNBPyI8glWh0FNGNPu2+jyaQMDgi4W0EdUaIWxK6gCrVVRIPVAtYwpOiwBpMracDZOApD/NcGc2Gu+DTIT6KkBWlRkuV5KhIX6ZM/DIpvj31Q8z8gsTSguUZerPDK6qB2eNvTnaGLmWW2MF0oIIsMSb4Fxjc0oJ+U9R8fujQkhdboqcc71pOyOaSmyy/GuUAglt60PGzbc8TSf2EzWLO0AOKhSaLkQiwLDZwoeaBdw1IYCxr4jNkuZ/0qozHFJBtY4bK1rfD3FTRgScqMJRoWGswKF6o1ZAVM2zmwOrLa/oUKc60SMIZUuE6HMjPcIbrC2sb67Je0XBKzMRay5sYyPEouLtDOeeEn90mwZnJ9FLJ4Ik0M1ABPLMtThLmfF9IWaP4atOmtohLuq2UUDsMxLcvmRvO17dnegIeAWmHdvM9fpnKhbquFjVYWuaOnl5FVdP0Un/iK8Rlbg2ZLeJ2nBgZOJNVEQxZKkyggk4AwyUk0IZmQhQVzTF6UiWZEZZHNQR8p+thAd+AYU5fTUQXdCmc4PB2VAZ28EbtldgN8dF4GNHorevQs/OQwD1yQKn92mhtJgDouvIpmJmSQKb15ncXfnBwpDJr44kqzyrK0ASikhWWH03coR7x+IiSZbw69PD89Pz8LMY4LDdAD8loDvIg4Ct/9Ek2Q9sJg1F5FvDfAX0QcR+/Gp+H4kAwfhMrwSluDDB/NJg9MJythIbGF7rEuL2/uSXNGe59gGT8bI+gjaAnprPee9PkL8VN2O1MbdzKMJsNw8C4aLEGCFgnqSPJilqhC2h7R/OTSH7e7VC1FwlJyeXN/EE9k+xvvYex1kaYb8q1gqVgI4ISrjAlJdkXj3uzvMB+E0bD6YZiozJ1loNcigVm21K/rmkphq02sje1/4wiYs+SRLXsC3nhhkrliVR/wl1sExCjqBUtatQPTmmHpIixknbVHezE+V0/tlYFdhU5bvh6/LBm9Gw2js/NhNIx2M85enrGA8zCOI7fPsORl2TCMoygejeLT03g8jn35u2CZSDc9+LkhjHONtYSf0g4oL9xi3JV/QoJ1lTvYM4TC0YfDFewi11iyn8B+V/pxuiNlv3iRey0ypjezmrLXTf7UKC/qaQQ3ivoE4LBgRWqJVodnV82aG/pT+XFQyxPp/XJKsJiybJ4CAWn1pqOKAmOFZP2KqPd74VpLxVLbmXDo/tCfJbPwnfVg/IMX7AZsZGprIfVgpbJzz8jxfrKLfcbMY39ElMYj5HL6/pZIZdlxfXfiYbEdS3rsprfIPfHiLaAoDHHtpFWj+bJ9HsUJH7AShqfHxWYjAfZsNUPcWDIOunnIdUfmOPMEHrJu+Sf2TzRYvuluK7l6siA5LirEIgutMrKfjR3IWnDQpu4cu7fLg8bxvWZC+qFLpbmS/njv6pOx++VF6voUhHeKEiW9CW/Y2v3o87b6GuMGVwfWFAH9t1iusApE+4V5rEceunrmuVIpMOk7TWa61rtrgY2dWc2kcd7MnusqOlrDo4oJp9SGPAPd3I8Vh8tdGI+T6wobaWaBE6k4kH3AXfwRLBUS3hB9kYGxLMv7uhtQWIPsaJzL4IWbD7cfGNNutPZjoLXqqDaOYlsbu7OiEci7KhZXbuAokFUWY+qSPR1kJYxVeEQc6mpcjVVcNnQ5MGxGaVnixHHX/dJUuqux1hXgX3TF9Ey8jk/FxvfdIe3mErtilqjE3Q3x9oZ57e83Gzdq1dXR0EfVX1e+rny/O1VzqmvS2ohOtbwAVL07aJFyVdhqt+F9uqe72kmc0FIyCcOy3NN4hVKNAshTGR1TeS9ZYVdKi/8BJwNycTMlj7Ahdb78JPZHIPb0mNhrpeeCc5BkQKbSFIuFSIS70gOdCWPc0faT3R+B3fFzN/xSWbJQhfy5TH8EIiddR6kT3IUDS1D2N73d/CT2byLWN4QrVb18YuDxrTGmJ47Kk+2uSiqpv9PyL5WNR84vSKFnqfnUWVu9shbrQse0q/ydEA2qD9e7WvY/v9+5Yq9+nWnWdGT/6oqnfOMdIqbRrrnPlbEZk/t7Kfcw3ErJw9ht9wn61lfkyl0LT/YkT5lwTUOh3VW1j2n1+ksDGtdV7IOvznFwu50zA/c6LUv8+VsBeHGOoV4zLbB5dU/EXBj8zGm8YKk5bEibDv3jtqqD/kl6PtA+48WuhZHYwLgXRBpTive/m+ZjdYmd3goYB+0s9cMXSQK5bUw82gfwWbhOwg9Xd9iAt3PoIGcceqdR262XuFOPIMuyttHidzSwLP8PPFIaHA== sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -68,7 +68,7 @@ Get detailed information about a specific agent by hostname.
    @@ -916,6 +916,97 @@ Get detailed information about a specific agent by hostname. +
    + + + Invalid hostname. + + +
    + + + + +
    + + + Schema + +
    + +
      + + + + + + + +
    +
    +
    + + + + +
    +
    +
    +
    +
    diff --git a/docs/docs/gen/api/list-jobs.api.mdx b/docs/docs/gen/api/list-jobs.api.mdx index 0943ec3c..9392d434 100644 --- a/docs/docs/gen/api/list-jobs.api.mdx +++ b/docs/docs/gen/api/list-jobs.api.mdx @@ -5,7 +5,7 @@ description: "Retrieve jobs, optionally filtered by status." sidebar_label: "List jobs" hide_title: true hide_table_of_contents: true -api: eJztWF+P2zYM/yqCnlbASXNdOwwB+pCtve2Kbju0d9hDdwhom050Z0s+iUovCwzsQ+wT7pMMlGLn/+VatEMH9CmxTVE/8kdSFBcyR5dZVZMyWg7lGySrcIbi2qQuESa8h7Kci0KVhBZzkc6FIyDv+jKRBBMnh+/kK5OOfwENE6xQ03h0fja+NunY1GiBVTh5lUiHmbeK5nL4biF/QLBoR56mvP7apEOLkMur5iqRNViokNC6IKqhQjmUcVeZSMVIbz3auUykxVuvLOZyWEDpMJF3PQO16mUmxwnqHt6RhV7EuZAzKFUOxOpMpQirmuaJ0WiK586nlSLCXNTWZOic0hORmaoukV8WoEr+BpYUlGN+9BZlk0iXTbEC1k7zOgK1Sk9kIlH7iq3rVMtErpTLRHbqZSLjBjJYv7HFVZNssXQauAgcrdPRJJ2vSlUp+gyuqpR+PthrtNKEE7QykZXSqmLDB4y7AF+SHJ4Mdqz4Be5YTmhfpWiFKaJBZIRF8lb3xaVDMRCFsUIbEUzaMNIUhcMvyspdI3/dMc7dqDrYVMNE6ZAdfclRb9HVRjsMuJ4MBvyzqWwkSuWoVdYPAaQJNbEo1HWpsqDv8bVj+cUufpNeY0YxDGu0pOJuZAjKMZvv9hq7CeOCpbdpq4CyKacMTXFZLBgg3gGHuBw+fcL+DJE6zozX5PbBgjxXseScbwLcRLTj5h9ZI2OBstzKC/FNl37JWmonq9xOlsmdbGf3o754gVbNOP2tqcQNzgXHnhO5t62tTAn/r8E58c9ff3OshhgTXNBc/w/NcLd9C9YCR+v2+wMEqXxffSmMrYDkUHqv8h2aLrW69ShUjppUoSJXjPjapH3ZsbFP8ZZzvbWoqfXnlpbMInAFO66GBZXRglSFjqCqw/rujNjnha3Am6LoxEUOBP2DMUPWYxOSKmTmdiqxqvgt6BGqWMVDgIXWGnvcqJcsJip0DibIWmIoBRVT4yiWqmNaRhP2bysvaArUhirmG972NVerfAx0XOtrcCTigi2fb5Sa+31+jrYHAV67KDqMK1hqDeQZ79LWoyP5eyC6DwUipzkQ7CmELaCWv/sou5eJpmmaRAYDxwzjw1yy1imExf+VVw5bmvudZNqwlOOgVBr3lKKtbJ1ao01pJiqDUji89agzXJZ7UaoCs3lWosAZagpWPrCUdZF4PIDP3v4mvv9ucLKK3rb4hF0j6bPl+XckT1lMsMzGeQDZjTbvS8wn/OQIbHi952iwoTPOH31UZhsrnPE2W6b2BDUXsWVqr2xZlpHjWn/2Fegeny+QlijWPu5x0IcUshwJVOm4kC37ibTEfoidEDzERzl3+y+C4JtlSZAbn18rR69M6ta/Nol8uq+lOdOhAxPctaEjsWr9P2F780AXjPY6Mvgl0GayjE/CfKOvkafxYhDb1nh5WjXkcunQB2ze1YiOBEiNpxWIvdvmHnlrjfTe2JuQKMZH4rnhfUg31xnJCzY2eTYYrPMaQmSH1JNdUi81eJoaq/7EXPTE6PwstE5dZ/6V2P8Dsd/uEntqbKryHLXoiTPtfFGoTIWDEG2lnAs3/a/sfvnsPttXi+MR0N5nPvEV8yuZn4nM0DfQ1ORyKCdhHFIDD9Xk42uTSp662VmcpK2N4N4ya5GY9UFcB3RKVMvl/IOf0yAkk+Wf0/bi+er3C8kIlC5MbO4izNj2rCaCfAbIRDKQaPFJf9API5baOKoghNJyrMPtQ4i+bTctVrH44YPKaBfhHT2uS1DhUu5tyVqjv8IQkseU3Nzx42KRgsNLWzYNv47zJZ5H5spxW7SaMB1EeWBQdwDODc7Xx5wzKD0LhZHXw/f8uLHavZDaaeJHInrgDOxeCN2sb4Xhih+sYhAc3NyWI+RoA0dx1SjLsF5ftVPBWEuXPj+9vODb2mYqbIV+0N7enPR8TfdiESUuzA3qppEtdOJn2Vw1TfMvwukvHg== +api: eJztWG1v20YM/iuH+9QCsmOn7TAY6IdsbbYU7Ra0KfahCwxKouxLpDvljnLjGQL2I/YL90sGnl4s+SV2i3bYgH6yJfF4JB/yOR5XMkYXWZWTMlpO5Fskq3CB4saELhDGv4c0XYpEpYQWYxEuhSOgwg1lIAlmTk4+yFcmnL4BDTPMUNP07PJiemPCqcnRAqtw8jqQDqPCKlrKyYeV/AHBoj0raM7rb0w4sQixvC6vA5mDhQwJrfOiGjKUE1ntKgOp2NK7Au1SBtLiXaEsxnKSQOowkPcDA7kaRCbGGeoB3pOFQWXnSi4gVTEQqzOZIsxyWgZGo0meuyLMFBHGIrcmQueUnonIZHmK/DIBlfI3sKQgnfJjYVGWgXTRHDNg7bTMK0Ot0jMZSNRFxt61qmUg18plIFv1MpDVBtJ739viugw2UDr3WHiMunCUQRurVGWKvkKoMqWfj4MM7p+PR6OdzitNOEMrA5kprTIOwDiQGdzX/0cj9iaBIiV+2vLtTSUpdJGFaIVJKjdztCKHGYpH48F4NHrc89YkicOv5e7RbnYd2/brl74/ZIS7VblIjPdLaV8mQ8npb9HlRjv0dp2ORvzTV3YmUuWoUTb0maQJNbEo5HmqIq/v5Max/GrbfhPeYERVPuZoSVW7kSFIp+y+2+ls34wrlt5EKgOK5lw7NMeaNdhAvAfOdTl5esrx9Ck7jUyhye0yC+JYVdxz2Tewb9FWmH9kjWwLpOlGgYhHbR0GnRoP1kUe1FUebJb546F4gVYtmAesycQtLgXnnhNxYRtfGRL+n4Nz4u8//xLaCJ9jgpnNDX/X3TCsOqwwedbjhclplxkmT0YtOUyebNPD5LQsA7kJGVgLXASb7/fgruJd/JUYmwHJiSwKFW+h/16ruwKFilGTSlSVAhyIGxMOZQvyLsUbmBXWoqYGpg0tkUXwcTiohgWV0YJUho4gy/369gzaFYWNfJ6jaMVFDATDvalItsDS16ov+M0KZVXVN69HqGSdZt4stNbYw069ZDGRoXNMfiqpM9SrmBtHFQMe0nI24/g28oLmQE0FYNyLdpEzCcZToMNaX4MjUS3YiHmPwR6O+SXaAXjzmkVVwJgYQ2sgjniXhuYO0MKe7N6XiMweQLCDXxuDGvweguxBJMqSq9M7OGUzPi0knU7EL/63orLf07jYKqaep5wHqdK4g4o2qnVujTapmakIUuHwrkAdYX2KiFQlGC2jFAUuUJP38kgqazPxcAJfvPtVfP/daLzO3oZ8/K4V6Iv6WD1QpywmWKZ3zEB0q83HFOMZPzkC61/vOHGs77zjx59V2cYKZwob1aU9Q80kVpf22peaRg5r/bnIQA/42IIwRdH5uCNAn0JkMRKo1DGR1W1KmOLQ545PHuKjkW8TL7zg25oSZO/za+XolQld92sZyKe7OqUL7Rs7wc0gOhLrq8UX7JqODMHZzkD6uHjYTBTxSRj32iV5Xl08yNQpssBOwy/rgB6xecsRLQgQmoLWRuzcNi6Qt9ZIH4299YViigp47qOPaRJbJ3lBb5Nno1EXV58iW6COt0F9r6GgubHqD4zFQJxdXviOrG34vwH7fwD2yTaw58aGKo5Ri4G40K5IEhUpfxCizZRzfpLwDd3/PrrPdnFxdQQ016QvfHP9BuZXAtP3DTQ3sZzImZ+y5MBDO3lyY0LJUz27qCZ1nRHfO0atAqY76GsNnRPlsh6r8HPohWRQ/zlvLp6vfruS/nqrE1M1d5WZVduznjjyGSADyYZUHo+Ho6Gf3OTGUQY+leppEbcPPvs2w7Ra5+KnD0Irvwjv6SRPQWl/mbIpa63i5YecPAbl5o4fV6sQHL63aVny62psxfPOWDlui9aDq71W7hkE7jHnFpfdMeoC0oKF/CTt+D2PHtA9aEUzoPxMI46cpj1oQjs1XNtwzQ9WsRGcz9yJI8RoPSzVqrMowry7aou0WEtbMT+9vOILWj/7N7Lda28uS3rZ0b1aVRJX5hZ1WcrGdOJnWV6XZfkP65RHhg== sidebar_class_name: "get api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -73,7 +73,7 @@ Retrieve jobs, optionally filtered by status. diff --git a/docs/docs/gen/api/post-file.api.mdx b/docs/docs/gen/api/post-file.api.mdx index 7ffd030f..bc46d5b9 100644 --- a/docs/docs/gen/api/post-file.api.mdx +++ b/docs/docs/gen/api/post-file.api.mdx @@ -5,7 +5,7 @@ description: "Upload a file to the Object Store." sidebar_label: "Upload a file" hide_title: true hide_table_of_contents: true -api: eJztWN9vGzcM/lcIPW3AxXFcO409DEM6LGsGFA2aBH1IgoB34vnUnKWrpIvjGve/D5Tu/CNx1j6sQAvkJfBFoviRH0lRXApJLrOq8spoMRGXVWlQAkKuSgJvwBcE79NPlHk498ZSTyTC49SJyZU4USXdvkONU5qR9rfHZ6e3LHdrKrLIJzpxk4jV16kUE3FmnGdBkQhHWW2VX4jJ1VK8IbRkj2tf8NF8zGRulSdx09wkokKLM/JkXdiscUZiInJjMz5IMfTPNdmFSISlz7WyJMUkx9JR8sjCjwVp8LamBNJFhc4FE6WakvOQFZTdAWoJWM5x4SBACDsYUQ8+kK+tdpAVqKckf+eDwNIUrSzJOTA5zAvyBdkglBntSXuQKs/JOsitmYUFelDOKz0FE3zbu9bsj6ygGYrJUvhFxfalxpSEvCQpx7r0rU0Nu4TtJOffGLlgkW0rL1rETGEdOO1tuSY4QLTwWHxWl15VaP1+buxsT6IPQJ5AinhFIirLvHpFjlcjIatdzlulp+Kx7xkV72Q3dT4FpXcGGT3grCr5MD1V+qGXGZ2LZoX5Nmr6msa3Zr7W5ApTlxJSAm8JPUmQNUuBpKo0ix5cC4vzaxFZd5Au+C+6PeV+g2vhaVaV6OlagCUtmc+58gX8bcDTg9/v1mP8TJn3HDPvIrkrBlkH26frGUd6/OpkxU2ThODfZRozg3xCqjSGWH+G9NZHPdE0m6RfRZpaBTdNE5ddZbSLNA76B09jibO1DSKS4OosI+fyuiwXzNNGDGFVlSoLqb7/ybHs9w6gFaqQnf8RNa7Awejw6wrO3x7vDUaHUKArtqJ05dJNHfQq7WfD4WB8lGcH2cFwjHmaD7Oj8fgwT8eD4eA10vCAhofDcTp+NcxwOB6Nxwfp66PRID0ajQIy9WXTcKU9Tck+ARZI4L2cLyEwt6Ac9AdDTo5Ylp4pIY/K4KpKbRrYVbYenHCl4Wr2ND8BS0soF1BQKQF1W8ViOvBmxxx1voy1NWbBCjAXoO+azfCLxTkYC11q/foIQci8ZxKkDZeWnbVfHwG+SYRXPhzG/MTL80ObUCLm17Dff5pSp/oeSyWhreFQ4aKr0f9TPpG1xn7do8ew8d0FfJAFX6AHk2W1tSS34/4EVUmS7xZL3iq6J3Aefe16HNGSPKrSfYNyKRX/xBJaGcDU1H4NYqdaWYdrTZOfG3sHXs3I1L4XLwf5Lcl0sTKSBbaUjPp9Zq2j9S/e9YTRHUXyUmPtC2PVF5KwB8dnp3BHC1jF1guxPwOxr3bcfsamSkrSsAen2tV5rjLFhbIiO1POhS73hd2fgd3xM71Nd5uFprxt6mLHHi7E9uaHS0fwR3hyxLbfGzD3ZEOzGO+WlyD40YNgtOs2DhvbXpIbiK6ZfKHzx6azScSMfGF4rFAZFzyPPD8Q+3k3YbD3cWZwsx43nDNvkZrNocMKauF91b3GQwsdNomk/XHSvcL++XgR+kelcxPEW6DH4em3notwMyASwUCizQe9fq/PPmLQM9Tr98/2/OWxs5briPzGQU00KLxOqxKVZqW1Lfmo6Kmr9i2YiIIdOLkSy2WKji5t2TT87zhV4ZmLVA7T8rm5yia2H3jEstMjd7TYGCbdY1nzHsFDlnu0iq3mCGoSURBKssEdUejPqH3vgs9dy+6apjRJJ3ScZVT5je1PqgurXoX22fvzC46+dtAzC4kVhgHh3GT9M4LE7XB+FL4BfLuEerGBYrmMOy7MHemmEZ1jPH+LhucF/wKdJ/xv +api: eJztWN9v2zYQ/lcIPm2A7DiuncYehiEdljUDigZNgj4kRnASTxYbiVRJKo5q6H8fjpT8O2sfVqAF8mJIFo/33X13x+MtuUCbGFk6qRWf8psy1yAYsFTmyJxmLkP2Pv6EiWNXThvs84g7mFs+veXnMsf7d6BgjgUqd392eXFPcve6RAO0o+WziK/eLgSf8kttHQnyiFtMKiNdzae3S/4GwaA5q1xGW9M204WRDvmsmUW8BAMFOjTWL1ZQIJ/yVJuENpIE/XOFpuYRN/i5kgYFn6aQW4z4U09DKXuJFjhH1cMnZ6AXbFjyR8ilAEe76UI6LEpX8ybaccvHDBVzpsKIxXUJ1nq/CDlH61iSYfLAQAkG+QJqyzxuv4LM6LMP6CqjLEsyUHMUv9NGzOAcjMjRWqZTtsjQZWi8UKKVQ+WYkGmKxrLU6MJ/wCdpnVRzpj0h/TtFTkwyLIBMcXVJZsRa5wj0SWAKVe5aRzTkR3IOWvdGi5pEtq28bhET75UPhP6WP70DeAuPxIsqd7IE445SbYqeAOeB7EEKeHnES0PB4CR63wcWV6usM1LN+a7vCRWtJDd1PmVSHYxMfIKizGkzNZfqqZ9olRKbLeb7oOlrGt/qxVqTzXSVCxYjcwbBoWCiIikmsMx13Wd33MDijgfWLYtr+gXbk/Y3dscponJweMeZQSWIz4V0GftbM4dP7qj7HuJnTrynkDgbyF0xSDrIPlUVlB7hrZPlsybyGXPINGIGaIdYKvAJ8gzprY/6vGk2Sb8NNLUKZk0TPttSKxtoHA6O92OJUrwNIhTMVkmC1qZVntfE00YMQVnmMvH14eiTJdnvHUArVD47/yNqbAbD8cnXFVy9PesNxycsA5ttRenKpZs68FU8SEaj4eQ0TY6T49EE0jgdJaeTyUkaT4aj4WvA0TGOTkaTePJqlMBoMp5MjuPXp+NhfDoee2Tyy6bhUjmco9kD5kmgtZQvPjC3oBwPhiNKjlCWnikhO2VwVaU2DewqW5+dU6WharafnwxygyBqlmEuGKi2ioV0oMWWOOp8GWpryIIVYCpA3zWb2S8GFkwb1qXWrzsIfOY9kyBtuLTsrP26A3gWcSed34z4CSfuhzaheMiv0WCwn1IXyh9WrK3hrIS6q9H/Uz6hMdp83aNnbOO9C3gvy1wGjukkqYxBsR335yBzFHS2GHRG4iMy68BVth/OWwcyt9+gXAhJj5CzVoZBrCu3BnFQraj8sabQLbR5YE4WqCvXD4eD+JZkul4ZSQJbSsaDAbHW0foXrdpj9ECRvFFQuUwb+QUF67Gzywv2gDVbxdYLsT8Dsa8OnH7axFIIVKzHLpSt0lQmkgpliaaQ1vrW+IXdn4HdyTO9TXea+aa8bepCx+4PxPbkZzcW2R/+nhLafqeZfkTjm8VwtrwEwY8eBONDp7Ff2PaS1EB0zeQLnT82nU3EC3SZpllEqa33PNDQgR+l3VjCPIZBw2w9o7gi3gI1m5OKFdTMubK7jfsW2i/iUftw3t3C/vl47ftHqVLtxVugZ/7qtx6mUDPAI05Ags3H/UF/QD4i0AWo9f1ne2iz66zlOiK/cboTDPK30zIHqUhpZXLaKnjqtr0LRjwjB05v+XIZg8UbkzcN/R1GMTSoEdJCnG8MY57F9gOPWA565AHrjQnUI+QVreE0ZHkEI8lqiqAm4hmCQOPdEYT+DNp717TvWvbQNKWJOqGzJMHSbSzfqy6kehXal++vrin62kFP4RPLDwP8vtH6MYCE7XDeCV8Pvv0Eqt5AsVyGFdf6AVXT8M4xjt55Q/OCfwEf8g+R sidebar_class_name: "post api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -68,7 +68,7 @@ Upload a file to the Object Store.
      diff --git a/docs/docs/gen/api/undrain-agent.api.mdx b/docs/docs/gen/api/undrain-agent.api.mdx index 2ba26015..1530c945 100644 --- a/docs/docs/gen/api/undrain-agent.api.mdx +++ b/docs/docs/gen/api/undrain-agent.api.mdx @@ -5,7 +5,7 @@ description: "Resume accepting jobs on a drained agent." sidebar_label: "Undrain an agent" hide_title: true hide_table_of_contents: true -api: eJztVl1r20oQ/SvLPN2CartfD1dvvtBACqUhdbgPwYSxdmxtIu2qs6O0rtB/v8xKdpw4Lb3QQgt58q52Ps8Z754OQkOM4oI/tZBD6y2j8/MNeYEMLMWCXaPHkMM5xbYmg0VBjTi/MddhFU3wBk3yImtQHSeQgeAmQn4JKdLVe/S4oVqX87PTq2R1tc8cYZlBpKJlJ1vILzv4h5CJ562UGiOZ55/ZCcGyX2bQIGNNQhyTtceaIIcyREnLDJyW26CUkAHTp9YxWciFW8ogFiXVCHkHsm3ULwo7v4H+YbuLkswupglrIyUN/RkJZgRqAloPU2yCjxQ16svZTH/ux0ow7JyM804cCllFqgheFO28A2yayhUJk+l1VMfuuNywuqZCyWlYERQ3pK0pRtzQI331hxhc7g2Xfa9Hr2cvjsu98NhKGdh9JWuem/nZqbmhrdmH+WlVE3Pg45ofUjE3B/sdF8nXSIliQlG0zAOe9AXrptJgJ+gqssoWk7CjWzJRUNo4GcgWdFX8geTWOl1iZUYfg6vQyl0Rj6a1LWlqT/I58I0RV1NoJaUugj0kynmhDfFR4sW+SXW4l+TNbKbkiZOU8q1anY9TCDtiXx0TexJ45awlb56bUx/b9doVTkezIa5djOnf+MTun8Du62/dMj6IWYfW/8zb5YnJX8jk399j0vnhcdX3NmVgG/SlVYToieHfn+E+g5qkDCqvmhAT8qpMcpgmNTHtdiKjn44CAVQO8e2gbw600UflcqDrUCHtyy9FGhgVju5XyQiycXESuEaBHN79u0iywPl1SO5j8cPU3Yk1ffghAy1kwOHFZDaZKW7aSI1pwEb1dTFqG/SDSHqIYXc3qP9PSA6tCX2RaVMpOH0GLVcaccBxFIiQQX4gAXdQLrMkDNWs61YY6YKrvtfPn1ri7QDwLbLDlWJw2YF1UdcW8jVWkb7Tx1/noyB6Zn5MLH6jnfEj+q2ijVWrO8jghraHurZf9hmUhJY4FToczxOKB45Ht4BK1P0Inn34uIAM8P7kPJiUFP7RqrpusFiEG/J9vy9SdK8V9v1/nZdG4w== +api: eJztV01v20YQ/SuLOTXA6sNudCiBHFSgBlS0qOHI6MEQjBE5Itcmd5ndoSOV4H8PZknJsuUECZAeDPikXXFm5817A/JtC64mj2ycXWSQQGMzj8bOc7IMGjIKqTe1PIYErig0FSlMU6rZ2FzduXVQzipUMYsyhZI4Bg2MeYDkBuJJt3+jxZwqWc4vF7cx6vZQOcBKQ6C08YZ3kNy08DuhJz9vuJAzYnjy2RsmWHUrDTV6rIjJhxhtsSJIoHCB41KDEbg1cgEaPH1qjKcMEvYNadiOHNZmlLqMcrIj2rLHUQ+3hQcsTYYsx+3zdGXshzNd4fbD+WwGnYaQFlShhPOultDA3tgcNFTG/kU2F9hnGirc7nfns1n3nM5lQWqPWbmN4oJ6/hQ7NQgxBunXU6idDRQhnk+n8vP0rEjzPkkZa9ggUyZKpM6yqJm0gHVdmjRyPrkLktieNuPWd5SK+LUXhdj0ZSsKAXM67brrjjm+OQSuuk4evX8J7sJGog/t/0Sc5L3zL2nzjDB1tN+zH3MVF8jKpWnjfc8gbbGqSznsAk1Jmejjib2hB1KBkZswhigvoynDdxTPMiNLLNWQo3DtGn4E8WLZrCEpbYk/O3+v2FTkGo6lZZqP6hrLlJM/Kbw8NCkJT4rMplORiw3Hkn9I1NUwd7CX8uxUymuLDRfOm/8oUyM1v1yoe9qpw0S8CfsahP31VNgL59cmy8iqkVrY0Gw2JjXylqnJVyaE+OJ+U/c1qPv+ax8M61htXGN/5ofiTcn/UcnfvqWksb0PE2sWK/jMiSkTht4+sa9A4U5DRVw4ceK1C5F5MbEJTKIxnLR7w9RNBq8H4pz9Q2+Fj2z0R9Gyl+vYTB/gF8w1DFZW9usYBHpYXDhfIUMCf/67jA7P2I2L6QP4fuoefb18+EGDAOl5OBtPx1PhTRqpMA7YYNSvB5uKtve7zzlsHwf1x+4cfWtMW57UpZDTaWh8KSf2PA53CdCQHN0W9lSudLxDSFjbrjHQtS+7Tv7+1JDf9QQ/oDe4Fg5uWshMkHUGyQbLQN/o45erwRC9U9/n+7/SzvAn2p2wjWUjO9BwT7vjK1C36jQUhBn5CLR/PI8sHiWevAXktnEYwct/Pi5BAz6dnGeTEo9/EVXb9hFLd0+26w4gWfaCsOu+AGpsBvk= sidebar_class_name: "post api-method" info_path: gen/api/agent-management-api custom_edit_url: null @@ -68,7 +68,7 @@ Resume accepting jobs on a drained agent.
        @@ -154,6 +154,97 @@ Resume accepting jobs on a drained agent. +
        + + + Invalid hostname. + + +
        + + + + +
        + + + Schema + +
        + +
          + + + + + + + +
        +
        +
        + + + + +
        +
        +
        +
        +
        diff --git a/docs/docs/sidebar/features/features.md b/docs/docs/sidebar/features/features.md index f9dbeeca..562fdae4 100644 --- a/docs/docs/sidebar/features/features.md +++ b/docs/docs/sidebar/features/features.md @@ -4,8 +4,7 @@ sidebar_position: 3 # Features -OSAPI provides a comprehensive set of features for managing Linux -systems. +OSAPI provides a comprehensive set of features for managing Linux systems. diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md index 319db82c..4abd7157 100644 --- a/docs/docs/sidebar/sdk/client/client.md +++ b/docs/docs/sidebar/sdk/client/client.md @@ -4,9 +4,8 @@ sidebar_position: 1 # Client -The `osapi` package provides a typed Go client for the OSAPI REST API. -Create a client with `New()` and use domain-specific services to interact -with the API. +The `osapi` package provides a typed Go client for the OSAPI REST API. Create a +client with `New()` and use domain-specific services to interact with the API. ## Quick Start @@ -32,13 +31,13 @@ resp, err := client.Node.Hostname(ctx, "_any") ## Client Options -| Option | Description | -| ------------------------------ | --------------------------------- | -| `WithLogger(logger)` | Set custom `slog.Logger` | -| `WithHTTPTransport(transport)` | Set custom `http.RoundTripper` | +| Option | Description | +| ------------------------------ | ------------------------------ | +| `WithLogger(logger)` | Set custom `slog.Logger` | +| `WithHTTPTransport(transport)` | Set custom `http.RoundTripper` | -`WithLogger` defaults to `slog.Default()`. `WithHTTPTransport` sets -the base transport for HTTP requests. +`WithLogger` defaults to `slog.Default()`. `WithHTTPTransport` sets the base +transport for HTTP requests. ## Targeting diff --git a/docs/docs/sidebar/sdk/client/file.md b/docs/docs/sidebar/sdk/client/file.md index 5583a8e1..825f3c0e 100644 --- a/docs/docs/sidebar/sdk/client/file.md +++ b/docs/docs/sidebar/sdk/client/file.md @@ -4,15 +4,14 @@ sidebar_position: 4 # FileService -File management operations for the OSAPI Object Store. Upload, list, -inspect, and delete files that can be deployed to agents via -`Node.FileDeploy`. +File management operations for the OSAPI Object Store. Upload, list, inspect, +and delete files that can be deployed to agents via `Node.FileDeploy`. ## Methods ### Object Store -| Method | Description | +| Method | Description | | ------------------------------- | ----------------------------------------------- | | `Upload(ctx, name, ct, r, ...)` | Upload file content to Object Store | | `Changed(ctx, name, r)` | Check if local content differs from stored file | @@ -22,8 +21,8 @@ inspect, and delete files that can be deployed to agents via ### Node File Operations -File deploy and status methods live on `NodeService` because they -target a specific host: +File deploy and status methods live on `NodeService` because they target a +specific host: | Method | Description | | ------------------------------- | ----------------------------------- | @@ -36,7 +35,7 @@ target a specific host: | ------------- | -------------- | -------- | ------------------------------------ | | `ObjectName` | string | Yes | Name of the file in Object Store | | `Path` | string | Yes | Destination path on the target host | -| `ContentType` | string | Yes | `"raw"` or `"template"` | +| `ContentType` | string | Yes | `"raw"` or `"template"` | | `Mode` | string | No | File permission mode (e.g. `"0644"`) | | `Owner` | string | No | File owner user | | `Group` | string | No | File owner group | @@ -109,28 +108,26 @@ resp, err := client.Node.FileStatus( ## Targeting -`FileDeploy` and `FileStatus` accept any valid target: `_any`, `_all`, -a hostname, or a label selector (`key:value`). +`FileDeploy` and `FileStatus` accept any valid target: `_any`, `_all`, a +hostname, or a label selector (`key:value`). -Object Store operations (`Upload`, `List`, `Get`, `Delete`) are -server-side and do not use targeting. +Object Store operations (`Upload`, `List`, `Get`, `Delete`) are server-side and +do not use targeting. ## Change Detection -`Upload` computes a SHA-256 of the file content locally before -uploading. If the hash matches the stored file, the upload is skipped -and `Changed: false` is returned. Use `WithForce()` to bypass this -check. +`Upload` computes a SHA-256 of the file content locally before uploading. If the +hash matches the stored file, the upload is skipped and `Changed: false` is +returned. Use `WithForce()` to bypass this check. -`Changed` performs the same SHA-256 comparison without uploading. It -returns `Changed: true` when the file does not exist or the content -differs. +`Changed` performs the same SHA-256 comparison without uploading. It returns +`Changed: true` when the file does not exist or the content differs. ## Idempotency -`FileDeploy` compares the SHA-256 of the Object Store content against -the deployed file. If the hashes match, the file is not rewritten and -the response reports `Changed: false`. +`FileDeploy` compares the SHA-256 of the Object Store content against the +deployed file. If the hashes match, the file is not rewritten and the response +reports `Changed: false`. ## Example @@ -140,6 +137,5 @@ for a complete working example. ## Permissions -Object Store operations require `file:read` (list, get) or `file:write` -(upload, delete). Deploy requires `file:write`. Status requires -`file:read`. +Object Store operations require `file:read` (list, get) or `file:write` (upload, +delete). Deploy requires `file:write`. Status requires `file:read`. diff --git a/docs/docs/sidebar/sdk/client/health.md b/docs/docs/sidebar/sdk/client/health.md index 286a2f47..bf0de274 100644 --- a/docs/docs/sidebar/sdk/client/health.md +++ b/docs/docs/sidebar/sdk/client/health.md @@ -35,5 +35,5 @@ for a complete working example. ## Permissions -`Liveness` and `Ready` are unauthenticated. `Status` requires -`health:read` permission. +`Liveness` and `Ready` are unauthenticated. `Status` requires `health:read` +permission. diff --git a/docs/docs/sidebar/sdk/client/job.md b/docs/docs/sidebar/sdk/client/job.md index 6ee4dd15..6301ffc0 100644 --- a/docs/docs/sidebar/sdk/client/job.md +++ b/docs/docs/sidebar/sdk/client/job.md @@ -44,5 +44,5 @@ for a complete working example. ## Permissions -Read operations require `job:read`. Write operations (create, delete, -retry) require `job:write`. +Read operations require `job:read`. Write operations (create, delete, retry) +require `job:write`. diff --git a/docs/docs/sidebar/sdk/client/node.md b/docs/docs/sidebar/sdk/client/node.md index b83a7927..ff9d03c1 100644 --- a/docs/docs/sidebar/sdk/client/node.md +++ b/docs/docs/sidebar/sdk/client/node.md @@ -4,9 +4,9 @@ sidebar_position: 8 # NodeService -Node management, network configuration, and command execution. This is -the largest service -- it combines node info, network, and command -operations that all target a specific host. +Node management, network configuration, and command execution. This is the +largest service -- it combines node info, network, and command operations that +all target a specific host. ## Methods @@ -24,11 +24,11 @@ operations that all target a specific host. ### Network -| Method | Description | -| ------------------------------------------------ | ---------------------- | -| `GetDNS(ctx, target, iface)` | Get DNS config | -| `UpdateDNS(ctx, target, iface, servers, search)` | Update DNS servers | -| `Ping(ctx, target, address)` | Ping a host | +| Method | Description | +| ------------------------------------------------ | ------------------ | +| `GetDNS(ctx, target, iface)` | Get DNS config | +| `UpdateDNS(ctx, target, iface, servers, search)` | Update DNS servers | +| `Ping(ctx, target, address)` | Ping a host | ### Command @@ -44,8 +44,8 @@ operations that all target a specific host. | `FileDeploy(ctx, opts)` | Deploy file to agent with SHA check | | `FileStatus(ctx, target, path)` | Check deployed file status | -See [`FileService`](file.md) for Object Store operations (upload, list, -get, delete) and `FileDeployOpts` details. +See [`FileService`](file.md) for Object Store operations (upload, list, get, +delete) and `FileDeployOpts` details. ## Usage @@ -103,7 +103,6 @@ for network and command examples. ## Permissions -Node info requires `node:read`. Network read requires `network:read`. -DNS updates require `network:write`. Commands require -`command:execute`. File deploy requires `file:write`. File status -requires `file:read`. +Node info requires `node:read`. Network read requires `network:read`. DNS +updates require `network:write`. Commands require `command:execute`. File deploy +requires `file:write`. File status requires `file:read`. diff --git a/docs/plans/2026-03-07-sdk-monorepo-migration-design.md b/docs/plans/2026-03-07-sdk-monorepo-migration-design.md index 233aff8f..b5d79a37 100644 --- a/docs/plans/2026-03-07-sdk-monorepo-migration-design.md +++ b/docs/plans/2026-03-07-sdk-monorepo-migration-design.md @@ -142,9 +142,9 @@ directories. Landing page uses `` cards. - **README.md**: Add SDK link in the docs/features section. Remove sibling repo references. - **CLAUDE.md**: Update SDK references to reflect `pkg/sdk/` location. Simplify - "Adding a New API Domain" Step 5 — no gilt, just - `go generate ./pkg/sdk/...`. Remove sibling repo references but keep SDK - documentation (now pointing to in-repo paths). + "Adding a New API Domain" Step 5 — no gilt, just `go generate ./pkg/sdk/...`. + Remove sibling repo references but keep SDK documentation (now pointing to + in-repo paths). - **docusaurus.config.ts**: Add "SDK" to the navbar Features dropdown. ## Cleanup @@ -155,8 +155,7 @@ directories. Landing page uses `` cards. - Update `pkg/sdk/osapi/gen/cfg.yaml` to reference `internal/api/gen/api.yaml` - Remove `generate.go` gilt step (oapi-codegen only) - Flatten `osapi-sdk/examples/osapi/` → `examples/sdk/osapi/` -- Update all `cmd/*.go` imports: - `github.com/osapi-io/osapi-sdk/pkg/osapi` → +- Update all `cmd/*.go` imports: `github.com/osapi-io/osapi-sdk/pkg/osapi` → `github.com/retr0h/osapi/pkg/sdk/osapi` - Remove `github.com/osapi-io/osapi-sdk` from `go.mod` - Create Docusaurus client pages diff --git a/docs/plans/2026-03-07-sdk-monorepo-migration.md b/docs/plans/2026-03-07-sdk-monorepo-migration.md index 7e34b1ac..982e32ea 100644 --- a/docs/plans/2026-03-07-sdk-monorepo-migration.md +++ b/docs/plans/2026-03-07-sdk-monorepo-migration.md @@ -7,9 +7,9 @@ as `pkg/sdk/osapi/`, flatten examples, add Docusaurus SDK docs, and update all references. -**Architecture:** Copy `osapi-sdk/pkg/osapi/` into `pkg/sdk/osapi/`, rewrite -the codegen to read the server's combined spec directly (no gilt), update all 18 -Go import paths, flatten 9 example directories into individual files, create +**Architecture:** Copy `osapi-sdk/pkg/osapi/` into `pkg/sdk/osapi/`, rewrite the +codegen to read the server's combined spec directly (no gilt), update all 18 Go +import paths, flatten 9 example directories into individual files, create Docusaurus SDK sidebar pages, and clean up CLAUDE.md/README.md references. **Tech Stack:** Go, oapi-codegen, Docusaurus, Cobra CLI @@ -125,9 +125,8 @@ git commit -m "feat(sdk): copy client library into pkg/sdk/osapi" **Step 1: Update all Go imports** -In every file listed below, replace -`"github.com/osapi-io/osapi-sdk/pkg/osapi"` with -`"github.com/retr0h/osapi/pkg/sdk/osapi"`: +In every file listed below, replace `"github.com/osapi-io/osapi-sdk/pkg/osapi"` +with `"github.com/retr0h/osapi/pkg/sdk/osapi"`: - `cmd/client.go` - `cmd/client_agent_get.go` @@ -208,20 +207,20 @@ cd examples/sdk/osapi && go mod tidy **Step 2: Create flattened example files** -Copy each example's `main.go` into a single file, updating the import path. -Each file is `package main` and self-contained. +Copy each example's `main.go` into a single file, updating the import path. Each +file is `package main` and self-contained. From `../osapi-sdk/examples/osapi/`: -| Source directory | Target file | -|---|---| -| `health/main.go` | `examples/sdk/osapi/health.go` | -| `node/main.go` | `examples/sdk/osapi/node.go` | -| `agent/main.go` | `examples/sdk/osapi/agent.go` | -| `audit/main.go` | `examples/sdk/osapi/audit.go` | +| Source directory | Target file | +| ----------------- | ------------------------------- | +| `health/main.go` | `examples/sdk/osapi/health.go` | +| `node/main.go` | `examples/sdk/osapi/node.go` | +| `agent/main.go` | `examples/sdk/osapi/agent.go` | +| `audit/main.go` | `examples/sdk/osapi/audit.go` | | `command/main.go` | `examples/sdk/osapi/command.go` | -| `file/main.go` | `examples/sdk/osapi/file.go` | -| `job/main.go` | `examples/sdk/osapi/job.go` | +| `file/main.go` | `examples/sdk/osapi/file.go` | +| `job/main.go` | `examples/sdk/osapi/job.go` | | `metrics/main.go` | `examples/sdk/osapi/metrics.go` | | `network/main.go` | `examples/sdk/osapi/network.go` | @@ -243,9 +242,9 @@ with: cd examples/sdk/osapi && go build ./... ``` -Note: `go build ./...` on `package main` files in the same directory will -verify they all compile. They won't run without a live server, but compilation -proves imports are correct. +Note: `go build ./...` on `package main` files in the same directory will verify +they all compile. They won't run without a live server, but compilation proves +imports are correct. **Step 4: Commit** @@ -314,8 +313,7 @@ Add "SDK" to the Features navbar dropdown: ``` Update the `specPath` for the API docs plugin from -`../../osapi-sdk/pkg/osapi/gen/api.yaml` to -`../internal/api/gen/api.yaml`. +`../../osapi-sdk/pkg/osapi/gen/api.yaml` to `../internal/api/gen/api.yaml`. **Step 5: Update the API docs specPath** @@ -375,9 +373,8 @@ Replace the entire Step 5 section (lines ~174-196) with: ```markdown ### Step 5: Update SDK -The SDK client library lives in `pkg/sdk/osapi/`. Its generated HTTP client -uses the same combined OpenAPI spec as the server -(`internal/api/gen/api.yaml`). +The SDK client library lives in `pkg/sdk/osapi/`. Its generated HTTP client uses +the same combined OpenAPI spec as the server (`internal/api/gen/api.yaml`). **When modifying existing API specs:** @@ -392,15 +389,14 @@ uses the same combined OpenAPI spec as the server **When adding a new API domain:** 1. Add a service wrapper in `pkg/sdk/osapi/{domain}.go` -2. Run `go generate ./pkg/sdk/osapi/gen/...` to pick up the new domain's - spec from the combined `api.yaml` +2. Run `go generate ./pkg/sdk/osapi/gen/...` to pick up the new domain's spec + from the combined `api.yaml` ``` **Step 3: Remove sibling repo references** -Remove any remaining references to `osapi-sdk` as a "sibling repo" or -"external" dependency. Keep documentation about the SDK but update paths to -`pkg/sdk/`. +Remove any remaining references to `osapi-sdk` as a "sibling repo" or "external" +dependency. Keep documentation about the SDK but update paths to `pkg/sdk/`. **Step 4: Commit** @@ -421,8 +417,7 @@ git commit -m "docs: update CLAUDE.md for in-repo SDK" **Step 1: Update README.md** Replace the "Sister Projects" section. Remove `osapi-sdk` from the sister -projects table (it's now in-repo). Add an SDK link in the Documentation -section: +projects table (it's now in-repo). Add an SDK link in the Documentation section: ```markdown ## 📖 Documentation @@ -434,8 +429,8 @@ section: - [Architecture](https://osapi-io.github.io/osapi/sidebar/architecture/) ``` -If `osapi-orchestrator` is still a separate repo, keep it in sister projects -but remove `osapi-sdk`. +If `osapi-orchestrator` is still a separate repo, keep it in sister projects but +remove `osapi-sdk`. **Step 2: Update system-architecture.md** @@ -506,22 +501,22 @@ cd docs && bun run build ## Files Summary -| Action | Path | -|---|---| -| Create | `pkg/sdk/osapi/*.go` (all source + test files) | -| Create | `pkg/sdk/osapi/gen/cfg.yaml` | -| Create | `pkg/sdk/osapi/gen/generate.go` | -| Create | `pkg/sdk/osapi/gen/client.gen.go` | -| Create | `examples/sdk/osapi/go.mod` | -| Create | `examples/sdk/osapi/*.go` (9 example files) | -| Create | `docs/docs/sidebar/sdk/sdk.md` | -| Create | `docs/docs/sidebar/sdk/client/client.md` | -| Create | `docs/docs/sidebar/sdk/client/{service}.md` (7 files) | -| Modify | `cmd/*.go` (11 files — import path) | -| Modify | `internal/audit/export/*.go` (5 files — import path) | +| Action | Path | +| ------ | ------------------------------------------------------- | +| Create | `pkg/sdk/osapi/*.go` (all source + test files) | +| Create | `pkg/sdk/osapi/gen/cfg.yaml` | +| Create | `pkg/sdk/osapi/gen/generate.go` | +| Create | `pkg/sdk/osapi/gen/client.gen.go` | +| Create | `examples/sdk/osapi/go.mod` | +| Create | `examples/sdk/osapi/*.go` (9 example files) | +| Create | `docs/docs/sidebar/sdk/sdk.md` | +| Create | `docs/docs/sidebar/sdk/client/client.md` | +| Create | `docs/docs/sidebar/sdk/client/{service}.md` (7 files) | +| Modify | `cmd/*.go` (11 files — import path) | +| Modify | `internal/audit/export/*.go` (5 files — import path) | | Modify | `internal/cli/ui.go`, `ui_public_test.go` (import path) | -| Modify | `go.mod` (remove external SDK) | -| Modify | `CLAUDE.md` | -| Modify | `README.md` | +| Modify | `go.mod` (remove external SDK) | +| Modify | `CLAUDE.md` | +| Modify | `README.md` | | Modify | `docs/docs/sidebar/architecture/system-architecture.md` | -| Modify | `docs/docusaurus.config.ts` | +| Modify | `docs/docusaurus.config.ts` | diff --git a/go.mod b/go.mod index a2a9fee2..0f708c78 100644 --- a/go.mod +++ b/go.mod @@ -143,7 +143,7 @@ require ( github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect - github.com/golangci/golangci-lint/v2 v2.11.1 // indirect + github.com/golangci/golangci-lint/v2 v2.11.2 // indirect github.com/golangci/golines v0.15.0 // indirect github.com/golangci/misspell v0.8.0 // indirect github.com/golangci/plugin-module-register v0.1.2 // indirect diff --git a/go.sum b/go.sum index 2fcfc63a..6769a950 100644 --- a/go.sum +++ b/go.sum @@ -422,6 +422,8 @@ github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0a github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY= github.com/golangci/golangci-lint/v2 v2.11.1 h1:aGbjflzzKNIdOoq/NawrhFjYpkNY4WzPSeIp2zBbzG8= github.com/golangci/golangci-lint/v2 v2.11.1/go.mod h1:wexdFBIQNhHNhDe1oqzlGFE5dYUqlfccWJKWjoWF1GI= +github.com/golangci/golangci-lint/v2 v2.11.2 h1:4Icd3mEqthcFcFww8L67OBtfKB/obXxko8aFUMqP/5w= +github.com/golangci/golangci-lint/v2 v2.11.2/go.mod h1:wexdFBIQNhHNhDe1oqzlGFE5dYUqlfccWJKWjoWF1GI= github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0= github.com/golangci/golines v0.15.0/go.mod h1:AZjXd23tbHMpowhtnGlj9KCNsysj72aeZVVHnVcZx10= github.com/golangci/misspell v0.8.0 h1:qvxQhiE2/5z+BVRo1kwYA8yGz+lOlu5Jfvtx2b04Jbg=