From 6905379e21859eaab4732965091d537a93e54ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 17 Feb 2026 20:26:10 -0800 Subject: [PATCH 1/2] feat: Add worker discovery, migrate CLI to REST, harden validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET /job/workers endpoint with CLI command for fleet discovery - Migrate all `client job` CLI commands from direct NATS to REST API - Rename --host/-H CLI flag to --target/-T across all commands - Add input validation for path params (interfaceName, job ID), query params (target_hostname, status), and missing 400 responses - Add public and integration tests for all validation cases 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ...26-02-17-api-input-validation-hardening.md | 44 ++ .../2026-02-17-api-list-nats-workers.md | 2 +- ...026-02-17-cli-consistent-host-targeting.md | 2 +- ...26-02-17-migrate-client-job-to-rest-api.md | 71 ++++ .tasks/sessions/2026-02-17.md | 103 ++++- cmd/client.go | 2 + cmd/client_job.go | 245 +---------- cmd/client_job_add.go | 63 ++- cmd/client_job_delete.go | 57 ++- cmd/client_job_get.go | 56 +-- cmd/client_job_list.go | 146 ++++--- cmd/client_job_run.go | 61 ++- cmd/client_job_status.go | 83 ++-- cmd/client_job_workers.go | 35 ++ cmd/client_job_workers_list.go | 89 ++++ cmd/client_network_dns_get.go | 9 +- cmd/client_network_dns_update.go | 12 + cmd/client_network_ping.go | 9 +- cmd/client_system_hostname_get.go | 9 +- cmd/client_system_status_get.go | 141 ++++--- cmd/root.go | 2 +- cmd/ui.go | 175 ++++---- docs/docs/sidebar/usage/cli/client/job/add.md | 2 +- .../sidebar/usage/cli/client/job/delete.md | 2 +- docs/docs/sidebar/usage/cli/client/job/get.md | 5 +- docs/docs/sidebar/usage/cli/client/job/job.md | 3 +- .../docs/sidebar/usage/cli/client/job/list.md | 2 +- .../usage/cli/client/job/workers/list.md | 20 + .../usage/cli/client/job/workers/workers.md | 7 + internal/api/job/gen/api.yaml | 76 ++++ internal/api/job/gen/job.gen.go | 124 ++++++ internal/api/job/job_delete.go | 8 + internal/api/job/job_delete_public_test.go | 52 ++- internal/api/job/job_get.go | 8 + internal/api/job/job_get_public_test.go | 78 +++- internal/api/job/job_list.go | 14 + internal/api/job/job_list_public_test.go | 39 +- internal/api/job/job_workers_get.go | 55 +++ .../api/job/job_workers_get_public_test.go | 114 +++++ internal/api/network/gen/api.yaml | 26 ++ internal/api/network/gen/network.gen.go | 75 +++- .../network/network_dns_get_by_interface.go | 70 ++- ...k_dns_get_by_interface_integration_test.go | 120 ++++++ ...etwork_dns_get_by_interface_public_test.go | 62 ++- .../network/network_dns_put_by_interface.go | 75 +++- ...k_dns_put_by_interface_integration_test.go | 2 +- ...etwork_dns_put_by_interface_public_test.go | 21 +- internal/api/network/network_ping_post.go | 80 +++- .../network_ping_post_integration_test.go | 2 +- .../network/network_ping_post_public_test.go | 19 +- internal/api/system/gen/api.yaml | 30 ++ internal/api/system/gen/system.gen.go | 67 ++- internal/api/system/system_hostname_get.go | 61 ++- .../system/system_hostname_get_public_test.go | 125 ++++++ internal/api/system/system_status_get.go | 73 +++- .../system_status_get_integration_test.go | 4 +- .../system/system_status_get_public_test.go | 123 ++++++ internal/client/client_public_test.go | 161 ++++++- internal/client/gen/api.yaml | 126 ++++++ internal/client/gen/client.gen.go | 397 +++++++++++++++--- internal/client/handler.go | 46 +- internal/client/job_delete_by_id.go | 35 ++ internal/client/job_get_by_id.go | 35 ++ internal/client/job_list.go | 40 ++ internal/client/job_post.go | 41 ++ internal/client/job_status_get.go | 34 ++ internal/client/job_workers_get.go | 34 ++ internal/client/network_dns_get.go | 7 +- internal/client/network_dns_put.go | 7 +- internal/client/network_ping_post.go | 7 +- internal/client/system_hostname_get.go | 7 +- internal/client/system_status_get.go | 7 +- internal/job/client/client.go | 27 +- internal/job/client/modify.go | 37 ++ internal/job/client/query.go | 132 ++++++ internal/job/client/types.go | 22 + internal/job/mocks/job_client.gen.go | 75 ++++ internal/job/types.go | 6 + 78 files changed, 3583 insertions(+), 760 deletions(-) create mode 100644 .tasks/done/2026-02-17-api-input-validation-hardening.md rename .tasks/{backlog => done}/2026-02-17-api-list-nats-workers.md (99%) rename .tasks/{backlog => done}/2026-02-17-cli-consistent-host-targeting.md (99%) create mode 100644 .tasks/done/2026-02-17-migrate-client-job-to-rest-api.md create mode 100644 cmd/client_job_workers.go create mode 100644 cmd/client_job_workers_list.go create mode 100644 docs/docs/sidebar/usage/cli/client/job/workers/list.md create mode 100644 docs/docs/sidebar/usage/cli/client/job/workers/workers.md create mode 100644 internal/api/job/job_workers_get.go create mode 100644 internal/api/job/job_workers_get_public_test.go create mode 100644 internal/api/network/network_dns_get_by_interface_integration_test.go create mode 100644 internal/api/system/system_hostname_get_public_test.go create mode 100644 internal/api/system/system_status_get_public_test.go create mode 100644 internal/client/job_delete_by_id.go create mode 100644 internal/client/job_get_by_id.go create mode 100644 internal/client/job_list.go create mode 100644 internal/client/job_post.go create mode 100644 internal/client/job_status_get.go create mode 100644 internal/client/job_workers_get.go diff --git a/.tasks/done/2026-02-17-api-input-validation-hardening.md b/.tasks/done/2026-02-17-api-input-validation-hardening.md new file mode 100644 index 00000000..bfcbe26f --- /dev/null +++ b/.tasks/done/2026-02-17-api-input-validation-hardening.md @@ -0,0 +1,44 @@ +--- +title: Complete API input validation hardening +status: done +created: 2026-02-17 +updated: 2026-02-17 +--- + +## Objective + +Harden all API endpoints by validating path parameters, query parameters, +and adding missing 400 responses to OpenAPI specs. The prior validation audit +only covered request body fields via `validate:` struct tags. + +## Changes + +### OpenAPI specs (400 responses added) + +- `internal/api/job/gen/api.yaml` — Added 400 to GET /job, GET /job/{id}, + DELETE /job/{id} +- `internal/api/system/gen/api.yaml` — Added 400 to GET /system/hostname, + GET /system/status + +### Handler validation (8 handlers) + +- `network_dns_get_by_interface.go` — interfaceName (required,alphanum) + + target_hostname (min=1 when provided) +- `network_dns_put_by_interface.go` — target_hostname validation +- `network_ping_post.go` — target_hostname validation +- `system_hostname_get.go` — target_hostname validation +- `system_status_get.go` — target_hostname validation +- `job_get.go` — ID (required,uuid) validation +- `job_delete.go` — ID (required,uuid) validation +- `job_list.go` — status enum validation + +### Tests (10 files) + +- Updated 5 existing public test files with validation cases +- Created 2 new public test files (system_hostname_get, system_status_get) +- Created 1 new integration test (network_dns_get_by_interface) + +## Outcome + +All handlers now validate path params, query params, and body fields. +0 lint issues, all tests pass. diff --git a/.tasks/backlog/2026-02-17-api-list-nats-workers.md b/.tasks/done/2026-02-17-api-list-nats-workers.md similarity index 99% rename from .tasks/backlog/2026-02-17-api-list-nats-workers.md rename to .tasks/done/2026-02-17-api-list-nats-workers.md index f1f8551e..2980f348 100644 --- a/.tasks/backlog/2026-02-17-api-list-nats-workers.md +++ b/.tasks/done/2026-02-17-api-list-nats-workers.md @@ -1,6 +1,6 @@ --- title: "Feature: API endpoint to list registered NATS workers" -status: backlog +status: done created: 2026-02-17 updated: 2026-02-17 --- diff --git a/.tasks/backlog/2026-02-17-cli-consistent-host-targeting.md b/.tasks/done/2026-02-17-cli-consistent-host-targeting.md similarity index 99% rename from .tasks/backlog/2026-02-17-cli-consistent-host-targeting.md rename to .tasks/done/2026-02-17-cli-consistent-host-targeting.md index 8a2fc99c..c18b83c5 100644 --- a/.tasks/backlog/2026-02-17-cli-consistent-host-targeting.md +++ b/.tasks/done/2026-02-17-cli-consistent-host-targeting.md @@ -1,6 +1,6 @@ --- title: Add consistent host targeting to all CLI commands -status: backlog +status: done created: 2026-02-17 updated: 2026-02-17 --- diff --git a/.tasks/done/2026-02-17-migrate-client-job-to-rest-api.md b/.tasks/done/2026-02-17-migrate-client-job-to-rest-api.md new file mode 100644 index 00000000..e109f3d9 --- /dev/null +++ b/.tasks/done/2026-02-17-migrate-client-job-to-rest-api.md @@ -0,0 +1,71 @@ +--- +title: Migrate client job commands from direct NATS to REST API +status: done +created: 2026-02-17 +updated: 2026-02-17 +completed: 2026-02-17 +--- + +## Objective + +All `client job` CLI commands currently bypass the REST API and connect +directly to NATS. This is inconsistent with `client system` and +`client network`, which go through the REST API. Everything under +`client` should use the same path: CLI → REST API → NATS. + +Current state: + +``` +client system * → REST API → NATS (correct) +client network * → REST API → NATS (correct) +client job * → NATS directly (inconsistent) +``` + +Target state: + +``` +client * → REST API → NATS (all consistent) +``` + +## Benefits + +- One entry point (API URL) — no NATS host/port needed in CLI +- Auth/middleware applies uniformly +- CLI doesn't need NATS credentials +- Can put load balancer/proxy in front of the API + +## Commands to Migrate + +All REST API endpoints already exist: + +| CLI Command | Current Path | REST Endpoint | +|---|---|---| +| `client job add` | Direct NATS | `POST /job` | +| `client job list` | Direct NATS | `GET /job` | +| `client job get` | Direct NATS | `GET /job/{id}` | +| `client job delete` | Direct NATS | `DELETE /job/{id}` | +| `client job status` | Direct NATS | `GET /job/status` | +| `client job run` | Direct NATS | `POST /job` + poll `GET /job/{id}` | +| `client job workers list` | Direct NATS | `GET /job/workers` | + +## Tasks + +- [x] Rewrite each command to use the REST client (`handler`) instead + of `jobClient` +- [x] Remove NATS setup from `client_job.go` `PersistentPreRun` +- [x] Remove `natsClient`, `jobsKV`, `jobClient` package vars +- [x] Remove NATS-related flags from `client_job.go` (nats-host, + nats-port, kv-bucket, stream-name, etc.) +- [x] Add any missing REST client handler methods if needed +- [x] Update tests +- [x] Verify `client job run` polling works through REST API + +## Notes + +- The generated REST client (`internal/client/gen/client.gen.go`) + already has methods for all endpoints. +- The `client` parent command already creates the REST client in its + `PersistentPreRun`. +- `client job run` is the most complex — it creates a job then polls + for completion. This maps to `POST /job` followed by polling + `GET /job/{id}`. diff --git a/.tasks/sessions/2026-02-17.md b/.tasks/sessions/2026-02-17.md index 83bcf37a..5d4d5e63 100644 --- a/.tasks/sessions/2026-02-17.md +++ b/.tasks/sessions/2026-02-17.md @@ -2,12 +2,12 @@ date: 2026-02-17 --- -## Summary +## Summary (Session 1) Implemented broadcast response collection for `_all` jobs, completing all gaps identified in the 2026-02-16 task. -## Changes +## Changes (Session 1) - `internal/job/client/client.go` — Added `publishAndCollect()` method that waits full timeout and collects all worker responses into a map @@ -33,7 +33,7 @@ gaps identified in the 2026-02-16 task. - Regenerated `internal/api/job/gen/job.gen.go` and `internal/job/mocks/job_client.gen.go`. -## Decisions +## Decisions (Session 1) - Used full timeout wait strategy (collect all responses until timeout, then return what was collected). @@ -42,6 +42,101 @@ gaps identified in the 2026-02-16 task. - Failed worker responses are skipped in `QuerySystemStatusAll` but still exposed in the REST API `responses` map. +## Summary (Session 2) + +Renamed `--host` CLI flag to `--target` and added worker discovery +endpoint (`GET /job/workers`) with CLI command. + +## Changes (Session 2) + +- `cmd/client.go` — Renamed `--host`/`-H` flag to `--target`/`-T` +- `cmd/client_system_status_get.go` — Updated flag read to `"target"` +- `cmd/client_system_hostname_get.go` — Updated flag read to `"target"` +- `cmd/client_network_dns_get.go` — Updated flag read to `"target"` +- `cmd/client_network_dns_update.go` — Updated flag read to `"target"` +- `cmd/client_network_ping.go` — Updated flag read to `"target"` +- `internal/job/types.go` — Added `WorkerInfo` struct +- `internal/job/client/types.go` — Added `ListWorkers` to interface +- `internal/job/client/query.go` — Implemented `ListWorkers` +- `internal/api/job/gen/api.yaml` — Added `/job/workers` endpoint +- `internal/client/gen/api.yaml` — Added `/job/workers` endpoint +- `internal/api/job/job_workers_get.go` — New handler +- `internal/api/job/job_workers_get_public_test.go` — 3 test cases +- `cmd/client_job_workers.go` — Parent `workers` command +- `cmd/client_job_workers_list.go` — `list` subcommand +- Regenerated all `*.gen.go` files + +## Decisions (Session 2) + +- `ListWorkers` reuses `QuerySystemHostnameAll` (broadcast + `system.hostname.get`) — no new operation type needed. +- CLI split into parent `workers` + `list` subcommand for extensibility. +- `--host` renamed to `--target`/`-T` per user preference. + +## Summary (Session 3) + +Migrated all `client job` CLI commands from direct NATS access to the REST API, +making them consistent with `client system` and `client network` commands. + +## Changes (Session 3) + +- `internal/client/gen/api.yaml` — Added `responses` and `worker_states` + fields to `JobDetailResponse` schema in client spec +- `internal/client/gen/client.gen.go` — Regenerated +- `internal/client/handler.go` — Added `JobHandler` interface (6 methods), + added to `CombinedHandler` +- `internal/client/job_post.go` — New handler implementation +- `internal/client/job_get_by_id.go` — New handler implementation +- `internal/client/job_delete_by_id.go` — New handler implementation +- `internal/client/job_list.go` — New handler implementation +- `internal/client/job_status_get.go` — New handler implementation +- `internal/client/job_workers_get.go` — New handler implementation +- `cmd/client_job.go` — Gutted: removed NATS setup, 30+ flags, viper bindings +- `cmd/client_job_add.go` — Rewritten to use REST API +- `cmd/client_job_get.go` — Rewritten to use REST API +- `cmd/client_job_delete.go` — Rewritten to use REST API +- `cmd/client_job_list.go` — Rewritten to use REST API +- `cmd/client_job_status.go` — Rewritten to use REST API +- `cmd/client_job_run.go` — Rewritten to use REST API +- `cmd/client_job_workers_list.go` — Rewritten to use REST API +- `cmd/ui.go` — Replaced `displayJobDetails` with `displayJobDetailResponse`; + removed `job` import +- `internal/client/client_public_test.go` — Added 6 handler tests +- Updated 5 doc files to remove NATS/KV-store wording +- Created `docs/.../job/workers/workers.md` and `list.md` + +## Decisions (Session 3) + +- Followed exact `SystemHandler`/`NetworkHandler` pattern for `JobHandler` +- `checkJobComplete()` now takes `jobHandler` parameter instead of package var +- Removed `extractTargetFromSubject()` — replaced with `Hostname` field + +## Summary (Session 4) + +Completed API input validation hardening for path params, query params, +and missing 400 responses. + +## Changes (Session 4) + +- `internal/api/job/gen/api.yaml` — Added 400 responses to GET /job, + GET /job/{id}, DELETE /job/{id} +- `internal/api/system/gen/api.yaml` — Added 400 responses to + GET /system/hostname, GET /system/status +- 8 handler files — Added inline struct validation for interfaceName + (alphanum), job ID (uuid), target_hostname (min=1), status (enum) +- Updated 6 existing public test files with validation cases +- Created system_hostname_get_public_test.go, + system_status_get_public_test.go (new) +- Created network_dns_get_by_interface_integration_test.go (new) + +## Decisions (Session 4) + +- Nil-check on `*string` pointer before validating target_hostname + (not `omitempty` which skips empty strings) +- Renamed inline struct field `Id` to `ID` per revive linter + ## Next Steps -- Pick up next task from `.tasks/backlog/`. +- Manual smoke test with running API server + NATS +- Consider adding integration tests for job_get, job_delete, job_list, + job_status, job_workers_get diff --git a/cmd/client.go b/cmd/client.go index 96665957..ce0a1add 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -62,6 +62,8 @@ func init() { clientCmd.PersistentFlags(). StringP("url", "", "http://0.0.0.0:8080", "URL the client will connect to") + clientCmd.PersistentFlags(). + StringP("target", "T", "_any", "Target hostname (_any, _all, or specific hostname)") _ = viper.BindPFlag("api.client.url", clientCmd.PersistentFlags().Lookup("url")) } diff --git a/cmd/client_job.go b/cmd/client_job.go index 1b2ea080..1ebd8801 100644 --- a/cmd/client_job.go +++ b/cmd/client_job.go @@ -21,258 +21,15 @@ package cmd import ( - "log/slog" - "time" - - "github.com/nats-io/nats.go" - natsclient "github.com/osapi-io/nats-client/pkg/client" "github.com/spf13/cobra" - "github.com/spf13/viper" - - "github.com/retr0h/osapi/internal/job/client" - "github.com/retr0h/osapi/internal/messaging" -) - -var ( - natsClient messaging.NATSClient - jobsKV nats.KeyValue - jobClient client.JobClient ) // clientJobCmd represents the clientJob command. var clientJobCmd = &cobra.Command{ Use: "job", - Short: "The job subcommand for direct NATS interaction", - Long: `The job subcommand allows direct interaction with the NATS job queue -for testing and debugging purposes. This bypasses the API and talks directly -to NATS using the nats-client library.`, - PersistentPreRun: func(cmd *cobra.Command, _ []string) { - validateDistribution() - - ctx := cmd.Context() - - logger.Debug( - "job client configuration", - slog.Bool("debug", appConfig.Debug), - slog.String("client.host", appConfig.Job.Client.Host), - slog.Int("client.port", appConfig.Job.Client.Port), - slog.String("client.client_name", appConfig.Job.Client.ClientName), - slog.String("stream_name", appConfig.Job.StreamName), - slog.String("stream_subjects", appConfig.Job.StreamSubjects), - slog.String("kv_bucket", appConfig.Job.KVBucket), - ) - - // Create NATS client - var nc messaging.NATSClient = natsclient.New(logger, &natsclient.Options{ - Host: appConfig.Job.Client.Host, - Port: appConfig.Job.Client.Port, - Auth: natsclient.AuthOptions{ - AuthType: natsclient.NoAuth, - }, - Name: appConfig.Job.Client.ClientName, - }) - natsClient = nc - - if err := natsClient.Connect(); err != nil { - logFatal("failed to connect to NATS", err) - } - - // Setup JOBS stream for subject routing - streamConfig := &nats.StreamConfig{ - Name: appConfig.Job.StreamName, - Subjects: []string{appConfig.Job.StreamSubjects}, - } - - if err := natsClient.CreateOrUpdateStreamWithConfig(ctx, streamConfig); err != nil { - logFatal("failed to setup JetStream", err) - } - - // Setup DLQ stream for failed jobs using advisory messages - dlqMaxAge, _ := time.ParseDuration(appConfig.Job.DLQ.MaxAge) - var dlqStorage nats.StorageType - if appConfig.Job.DLQ.Storage == "memory" { - dlqStorage = nats.MemoryStorage - } else { - dlqStorage = nats.FileStorage - } - - dlqStreamConfig := &nats.StreamConfig{ - Name: "JOBS-DLQ", - Subjects: []string{ - "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES." + appConfig.Job.StreamName + ".*", - }, - Storage: dlqStorage, - MaxAge: dlqMaxAge, - MaxMsgs: appConfig.Job.DLQ.MaxMsgs, - Replicas: appConfig.Job.DLQ.Replicas, - Metadata: map[string]string{ - "dead_letter_queue": "true", - }, - } - if err := natsClient.CreateOrUpdateStreamWithConfig(ctx, dlqStreamConfig); err != nil { - logFatal("failed to setup DLQ stream", err) - } - - // Create/get the jobs KV bucket - var err error - jobsKV, err = natsClient.CreateKVBucket(appConfig.Job.KVBucket) - if err != nil { - logFatal("failed to create KV bucket", err) - } - - // Create job client - var jc client.JobClient - jc, err = client.New(logger, natsClient, &client.Options{ - Timeout: 30 * time.Second, // Default timeout - KVBucket: jobsKV, - }) - if err != nil { - logFatal("failed to create job client", err) - } - jobClient = jc - }, - PersistentPostRun: func(_ *cobra.Command, _ []string) { - if natsClient != nil { - if nc, ok := natsClient.(*natsclient.Client); ok && nc.NC != nil { - nc.NC.Close() - } - } - }, + Short: "Job management commands", } func init() { clientCmd.AddCommand(clientJobCmd) - - clientJobCmd.PersistentFlags(). - StringP("nats-host", "", "localhost", "NATS server hostname") - clientJobCmd.PersistentFlags(). - IntP("nats-port", "", 4222, "NATS server port") - clientJobCmd.PersistentFlags(). - StringP("client-name", "", "osapi-jobs-cli", "NATS client name") - clientJobCmd.PersistentFlags(). - StringP("kv-bucket", "", "job-queue", "KV bucket name for job storage") - clientJobCmd.PersistentFlags(). - StringP("stream-name", "", "JOBS", "JetStream stream name") - clientJobCmd.PersistentFlags(). - StringP("stream-subjects", "", "jobs.>", "JetStream stream subjects pattern") - clientJobCmd.PersistentFlags(). - StringP("kv-response-bucket", "", "job-responses", "KV bucket name for job responses") - clientJobCmd.PersistentFlags(). - StringP("consumer-name", "", "jobs-worker", "JetStream consumer name") - - // Stream configuration - clientJobCmd.PersistentFlags(). - StringP("stream-max-age", "", "24h", "JetStream stream max age") - clientJobCmd.PersistentFlags(). - IntP("stream-max-msgs", "", 10000, "JetStream stream max messages") - clientJobCmd.PersistentFlags(). - StringP("stream-storage", "", "file", "JetStream stream storage type") - clientJobCmd.PersistentFlags(). - IntP("stream-replicas", "", 1, "JetStream stream replicas") - clientJobCmd.PersistentFlags(). - StringP("stream-discard", "", "old", "JetStream stream discard policy") - - // Consumer configuration - clientJobCmd.PersistentFlags(). - IntP("consumer-max-deliver", "", 5, "JetStream consumer max deliver attempts") - clientJobCmd.PersistentFlags(). - StringP("consumer-ack-wait", "", "30s", "JetStream consumer ack wait time") - clientJobCmd.PersistentFlags(). - IntP("consumer-max-ack-pending", "", 100, "JetStream consumer max ack pending") - clientJobCmd.PersistentFlags(). - StringP("consumer-replay-policy", "", "instant", "JetStream consumer replay policy") - - // KeyValue bucket configuration - clientJobCmd.PersistentFlags(). - StringP("kv-ttl", "", "1h", "KV bucket TTL") - clientJobCmd.PersistentFlags(). - IntP("kv-max-bytes", "", 104857600, "KV bucket max bytes (100MB)") - clientJobCmd.PersistentFlags(). - StringP("kv-storage", "", "file", "KV bucket storage type") - clientJobCmd.PersistentFlags(). - IntP("kv-replicas", "", 1, "KV bucket replicas") - - // DLQ configuration - clientJobCmd.PersistentFlags(). - StringP("dlq-max-age", "", "7d", "DLQ stream max age") - clientJobCmd.PersistentFlags(). - IntP("dlq-max-msgs", "", 1000, "DLQ stream max messages") - clientJobCmd.PersistentFlags(). - StringP("dlq-storage", "", "file", "DLQ stream storage type") - clientJobCmd.PersistentFlags(). - IntP("dlq-replicas", "", 1, "DLQ stream replicas") - - // Bind flags to viper config - _ = viper.BindPFlag("job.client.host", clientJobCmd.PersistentFlags().Lookup("nats-host")) - _ = viper.BindPFlag("job.client.port", clientJobCmd.PersistentFlags().Lookup("nats-port")) - _ = viper.BindPFlag( - "job.client.client_name", - clientJobCmd.PersistentFlags().Lookup("client-name"), - ) - _ = viper.BindPFlag("job.kv_bucket", clientJobCmd.PersistentFlags().Lookup("kv-bucket")) - _ = viper.BindPFlag("job.stream_name", clientJobCmd.PersistentFlags().Lookup("stream-name")) - _ = viper.BindPFlag( - "job.stream_subjects", - clientJobCmd.PersistentFlags().Lookup("stream-subjects"), - ) - _ = viper.BindPFlag( - "job.kv_response_bucket", - clientJobCmd.PersistentFlags().Lookup("kv-response-bucket"), - ) - _ = viper.BindPFlag( - "job.consumer_name", - clientJobCmd.PersistentFlags().Lookup("consumer-name"), - ) - - // Stream configuration bindings - _ = viper.BindPFlag( - "job.stream.max_age", - clientJobCmd.PersistentFlags().Lookup("stream-max-age"), - ) - _ = viper.BindPFlag( - "job.stream.max_msgs", - clientJobCmd.PersistentFlags().Lookup("stream-max-msgs"), - ) - _ = viper.BindPFlag( - "job.stream.storage", - clientJobCmd.PersistentFlags().Lookup("stream-storage"), - ) - _ = viper.BindPFlag( - "job.stream.replicas", - clientJobCmd.PersistentFlags().Lookup("stream-replicas"), - ) - _ = viper.BindPFlag( - "job.stream.discard", - clientJobCmd.PersistentFlags().Lookup("stream-discard"), - ) - - // Consumer configuration bindings - _ = viper.BindPFlag( - "job.consumer.max_deliver", - clientJobCmd.PersistentFlags().Lookup("consumer-max-deliver"), - ) - _ = viper.BindPFlag( - "job.consumer.ack_wait", - clientJobCmd.PersistentFlags().Lookup("consumer-ack-wait"), - ) - _ = viper.BindPFlag( - "job.consumer.max_ack_pending", - clientJobCmd.PersistentFlags().Lookup("consumer-max-ack-pending"), - ) - _ = viper.BindPFlag( - "job.consumer.replay_policy", - clientJobCmd.PersistentFlags().Lookup("consumer-replay-policy"), - ) - - // KeyValue configuration bindings - _ = viper.BindPFlag("job.kv.ttl", clientJobCmd.PersistentFlags().Lookup("kv-ttl")) - _ = viper.BindPFlag("job.kv.max_bytes", clientJobCmd.PersistentFlags().Lookup("kv-max-bytes")) - _ = viper.BindPFlag("job.kv.storage", clientJobCmd.PersistentFlags().Lookup("kv-storage")) - _ = viper.BindPFlag("job.kv.replicas", clientJobCmd.PersistentFlags().Lookup("kv-replicas")) - - // DLQ configuration bindings - _ = viper.BindPFlag("job.dlq.max_age", clientJobCmd.PersistentFlags().Lookup("dlq-max-age")) - _ = viper.BindPFlag("job.dlq.max_msgs", clientJobCmd.PersistentFlags().Lookup("dlq-max-msgs")) - _ = viper.BindPFlag("job.dlq.storage", clientJobCmd.PersistentFlags().Lookup("dlq-storage")) - _ = viper.BindPFlag("job.dlq.replicas", clientJobCmd.PersistentFlags().Lookup("dlq-replicas")) } diff --git a/cmd/client_job_add.go b/cmd/client_job_add.go index 6bb008d2..cc8e8b0f 100644 --- a/cmd/client_job_add.go +++ b/cmd/client_job_add.go @@ -22,19 +22,22 @@ package cmd import ( "encoding/json" + "fmt" "io" "log/slog" + "net/http" "os" "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" ) // clientJobAddCmd represents the clientJobAdd command. var clientJobAddCmd = &cobra.Command{ Use: "add", - Short: "Add a job directly in NATS queue", - Long: `Adds a job directly in the NATS job queue using the nats-client library. -This bypasses the API and talks directly to NATS for testing purposes.`, + Short: "Add a job to the queue", + Long: `Adds a job to the queue via the REST API for processing.`, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() @@ -52,36 +55,50 @@ This bypasses the API and talks directly to NATS for testing purposes.`, logFatal("failed to read file", err) } - // Parse the JSON operation - using generic structure to handle new format var operationData map[string]interface{} if err := json.Unmarshal(fileContents, &operationData); err != nil { logFatal("failed to parse JSON operation file", err) } - // Use the job client to create the job - result, err := jobClient.CreateJob(ctx, operationData, targetHostname) + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.PostJob(ctx, operationData, targetHostname) if err != nil { logFatal("failed to create job", err) } - if jsonOutput { - resultJSON, _ := json.Marshal(result) - logger.Info("job created", slog.String("response", string(resultJSON))) - return - } - - jobData := map[string]interface{}{ - "Job ID": result.JobID, - "Status": result.Status, - "Revision": result.Revision, + switch resp.StatusCode() { + case http.StatusCreated: + if jsonOutput { + fmt.Println(string(resp.Body)) + return + } + + if resp.JSON201 == nil { + logFatal("failed response", fmt.Errorf("create job response was nil")) + } + + jobData := map[string]interface{}{ + "Job ID": resp.JSON201.JobId, + "Status": resp.JSON201.Status, + } + if resp.JSON201.Revision != nil { + jobData["Revision"] = *resp.JSON201.Revision + } + printStyledMap(jobData) + + logger.Info("job created successfully", + slog.String("job_id", resp.JSON201.JobId), + slog.String("target_hostname", targetHostname), + ) + case http.StatusBadRequest: + handleUnknownError(resp.JSON400, resp.StatusCode(), logger) + case http.StatusUnauthorized: + handleAuthError(resp.JSON401, resp.StatusCode(), logger) + case http.StatusForbidden: + handleAuthError(resp.JSON403, resp.StatusCode(), logger) + default: + handleUnknownError(resp.JSON500, resp.StatusCode(), logger) } - printStyledMap(jobData) - - logger.Info("job created successfully", - slog.String("job_id", result.JobID), - slog.Uint64("revision", result.Revision), - slog.String("target_hostname", targetHostname), - ) }, } diff --git a/cmd/client_job_delete.go b/cmd/client_job_delete.go index 8c122610..02d4755b 100644 --- a/cmd/client_job_delete.go +++ b/cmd/client_job_delete.go @@ -22,47 +22,62 @@ package cmd import ( "encoding/json" + "fmt" "log/slog" + "net/http" "time" "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" ) // clientJobDeleteCmd represents the clientJobsDelete command. var clientJobDeleteCmd = &cobra.Command{ Use: "delete", - Short: "Delete a job from KV store", - Long: `Deletes a specific job from the NATS KV store using its job ID. -This removes the job permanently from storage.`, + Short: "Delete a job", + Long: `Deletes a specific job by its ID via the REST API.`, Run: func(cmd *cobra.Command, _ []string) { jobID, _ := cmd.Flags().GetString("job-id") ctx := cmd.Context() - err := jobClient.DeleteJob(ctx, jobID) + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.DeleteJobByID(ctx, jobID) if err != nil { logFatal("failed to delete job", err) } - if jsonOutput { - result := map[string]interface{}{ - "status": "deleted", - "job_id": jobID, - "timestamp": time.Now().Format(time.RFC3339), + switch resp.StatusCode() { + case http.StatusNoContent: + if jsonOutput { + result := map[string]interface{}{ + "status": "deleted", + "job_id": jobID, + "timestamp": time.Now().Format(time.RFC3339), + } + resultJSON, _ := json.Marshal(result) + fmt.Println(string(resultJSON)) + return } - resultJSON, _ := json.Marshal(result) - logger.Info("job deleted", slog.String("response", string(resultJSON))) - return - } - deleteData := map[string]interface{}{ - "Job ID": jobID, - "Status": "Deleted", - } - printStyledMap(deleteData) + deleteData := map[string]interface{}{ + "Job ID": jobID, + "Status": "Deleted", + } + printStyledMap(deleteData) - logger.Info("job deleted successfully", - slog.String("job_id", jobID), - ) + logger.Info("job deleted successfully", + slog.String("job_id", jobID), + ) + case http.StatusNotFound: + handleUnknownError(resp.JSON404, resp.StatusCode(), logger) + case http.StatusUnauthorized: + handleAuthError(resp.JSON401, resp.StatusCode(), logger) + case http.StatusForbidden: + handleAuthError(resp.JSON403, resp.StatusCode(), logger) + default: + handleUnknownError(resp.JSON500, resp.StatusCode(), logger) + } }, } diff --git a/cmd/client_job_get.go b/cmd/client_job_get.go index cb4c60d1..5fcfc8ce 100644 --- a/cmd/client_job_get.go +++ b/cmd/client_job_get.go @@ -21,52 +21,56 @@ package cmd import ( - "encoding/json" + "fmt" "log/slog" + "net/http" "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" ) // clientJobGetCmd represents the clientJobsGet command. var clientJobGetCmd = &cobra.Command{ Use: "get", - Short: "Get job details and status from KV store", - Long: `Retrieves a job's details and current status from the NATS KV store. -Shows the job data, status (unprocessed/processing/completed/failed), -and any results if completed.`, + Short: "Get job details and status", + Long: `Retrieves a job's details and current status via the REST API.`, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() jobID, _ := cmd.Flags().GetString("job-id") - // Use job client to get job status - jobInfo, err := jobClient.GetJobStatus(ctx, jobID) + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.GetJobByID(ctx, jobID) if err != nil { + logFatal("failed to get job", err) + } + + switch resp.StatusCode() { + case http.StatusOK: if jsonOutput { - result := map[string]interface{}{ - "status": "not_found", - "job_id": jobID, - "message": err.Error(), - } - resultJSON, _ := json.Marshal(result) - logger.Info("job", slog.String("response", string(resultJSON))) + fmt.Println(string(resp.Body)) return } - jobData := map[string]interface{}{ - "Job ID": jobID, - "Status": "Not Found", - "Message": err.Error(), + if resp.JSON200 == nil { + logFatal("failed response", fmt.Errorf("job detail response was nil")) } - printStyledMap(jobData) - return - } - displayJobDetails(jobInfo) + displayJobDetailResponse(resp.JSON200) - logger.Info("job retrieved successfully", - slog.String("job_id", jobID), - slog.String("status", jobInfo.Status), - ) + logger.Info("job retrieved successfully", + slog.String("job_id", jobID), + slog.String("status", safeString(resp.JSON200.Status)), + ) + case http.StatusNotFound: + handleUnknownError(resp.JSON404, resp.StatusCode(), logger) + case http.StatusUnauthorized: + handleAuthError(resp.JSON401, resp.StatusCode(), logger) + case http.StatusForbidden: + handleAuthError(resp.JSON403, resp.StatusCode(), logger) + default: + handleUnknownError(resp.JSON500, resp.StatusCode(), logger) + } }, } diff --git a/cmd/client_job_list.go b/cmd/client_job_list.go index e3d1a3e6..768ff011 100644 --- a/cmd/client_job_list.go +++ b/cmd/client_job_list.go @@ -24,96 +24,122 @@ import ( "encoding/json" "fmt" "log/slog" - "strings" + "net/http" "time" "github.com/spf13/cobra" - "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/client" + "github.com/retr0h/osapi/internal/client/gen" ) -// extractTargetFromSubject extracts the target hostname from a job subject -// Expected format: job.{type}.{hostname}.{category}.{operation} -func extractTargetFromSubject( - subject string, -) string { - if subject == "" { - return "unknown" - } - - parts := strings.Split(subject, ".") - if len(parts) < 3 { - return "unknown" - } - - // Return the hostname part (3rd element) - return parts[2] -} - // clientJobListCmd represents the clientJobsList command. var clientJobListCmd = &cobra.Command{ Use: "list", - Short: "List jobs from KV store", - Long: `Lists jobs stored in the NATS KV bucket with their current status computed from events. -Shows job IDs, creation time, status, target, operation type, and worker information. -Job status is computed in real-time from append-only status events.`, + Short: "List jobs", + Long: `Lists jobs with their current status via the REST API.`, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() - // Get filter flags statusFilter, _ := cmd.Flags().GetString("status") limitFlag, _ := cmd.Flags().GetInt("limit") offsetFlag, _ := cmd.Flags().GetInt("offset") - // Use job client to get jobs (this computes status from events) - jobs, err := jobClient.ListJobs(ctx, statusFilter) + jobHandler := handler.(client.JobHandler) + + // Get jobs list + jobsResp, err := jobHandler.GetJobs(ctx, statusFilter) if err != nil { logFatal("failed to list jobs", err) } - // Apply offset if specified + if jobsResp.StatusCode() != http.StatusOK { + switch jobsResp.StatusCode() { + case http.StatusUnauthorized: + handleAuthError(jobsResp.JSON401, jobsResp.StatusCode(), logger) + case http.StatusForbidden: + handleAuthError(jobsResp.JSON403, jobsResp.StatusCode(), logger) + default: + handleUnknownError(jobsResp.JSON500, jobsResp.StatusCode(), logger) + } + return + } + + // Get queue stats for summary + statsResp, err := jobHandler.GetJobQueueStats(ctx) + if err != nil { + logFatal("failed to get queue stats", err) + } + + if statsResp.StatusCode() != http.StatusOK { + handleUnknownError(statsResp.JSON500, statsResp.StatusCode(), logger) + return + } + + // Extract jobs from response + var jobs []gen.JobDetailResponse + if jobsResp.JSON200 != nil && jobsResp.JSON200.Items != nil { + jobs = *jobsResp.JSON200.Items + } + + // Apply offset if offsetFlag > 0 && offsetFlag < len(jobs) { jobs = jobs[offsetFlag:] } else if offsetFlag >= len(jobs) { - jobs = []*job.QueuedJob{} // No jobs to show if offset exceeds total + jobs = []gen.JobDetailResponse{} } - // Apply limit if specified + // Apply limit if limitFlag > 0 && len(jobs) > limitFlag { jobs = jobs[:limitFlag] } - // Get queue stats for summary - stats, err := jobClient.GetQueueStats(ctx) - if err != nil { - logFatal("failed to get queue stats", err) - } + stats := statsResp.JSON200 if jsonOutput { + totalJobs := 0 + if stats != nil && stats.TotalJobs != nil { + totalJobs = *stats.TotalJobs + } + statusCounts := map[string]int{} + if stats != nil && stats.StatusCounts != nil { + statusCounts = *stats.StatusCounts + } + result := map[string]interface{}{ - "total_jobs": stats.TotalJobs, + "total_jobs": totalJobs, "displayed_jobs": len(jobs), - "status_counts": stats.StatusCounts, + "status_counts": statusCounts, "filter_applied": statusFilter != "", "limit_applied": limitFlag > 0, "offset_applied": offsetFlag, "jobs": jobs, } resultJSON, _ := json.Marshal(result) - logger.Info("jobs list", slog.String("response", string(resultJSON))) + fmt.Println(string(resultJSON)) return } - // Display summary (always show total counts) + // Display summary + totalJobs := 0 + statusCounts := map[string]int{} + if stats != nil { + if stats.TotalJobs != nil { + totalJobs = *stats.TotalJobs + } + if stats.StatusCounts != nil { + statusCounts = *stats.StatusCounts + } + } + summaryData := map[string]interface{}{ - "Total Jobs": stats.TotalJobs, - "Submitted": stats.StatusCounts["submitted"], - "Processing": stats.StatusCounts["processing"], - "Completed": stats.StatusCounts["completed"], - "Failed": stats.StatusCounts["failed"], - "Partial": stats.StatusCounts["partial_failure"], + "Total Jobs": totalJobs, + "Submitted": statusCounts["submitted"], + "Processing": statusCounts["processing"], + "Completed": statusCounts["completed"], + "Failed": statusCounts["failed"], + "Partial": statusCounts["partial_failure"], } - // Add filter info if applied if statusFilter != "" { summaryData["Showing ("+statusFilter+")"] = len(jobs) } else { @@ -128,32 +154,27 @@ Job status is computed in real-time from append-only status events.`, printStyledMap(summaryData) - // Display job details if len(jobs) > 0 { jobRows := [][]string{} - for _, job := range jobs { - // Format created time - created := job.Created - if t, err := time.Parse(time.RFC3339, job.Created); err == nil { + for _, j := range jobs { + created := safeString(j.Created) + if t, err := time.Parse(time.RFC3339, created); err == nil { created = t.Format("2006-01-02 15:04") } - // Get operation summary operationSummary := "Unknown" - if job.Operation != nil { - if operationType, ok := job.Operation["type"].(string); ok { + if j.Operation != nil { + if operationType, ok := (*j.Operation)["type"].(string); ok { operationSummary = operationType } } - // Get target from subject - target := extractTargetFromSubject(job.Subject) + target := safeString(j.Hostname) - // Get worker info if available workers := "" - if len(job.WorkerStates) > 0 { + if j.WorkerStates != nil && len(*j.WorkerStates) > 0 { var workerList []string - for hostname := range job.WorkerStates { + for hostname := range *j.WorkerStates { workerList = append(workerList, hostname) } if len(workerList) == 1 { @@ -164,8 +185,8 @@ Job status is computed in real-time from append-only status events.`, } jobRows = append(jobRows, []string{ - job.ID, - job.Status, + safeString(j.Id), + safeString(j.Status), created, target, operationSummary, @@ -191,7 +212,7 @@ Job status is computed in real-time from append-only status events.`, } logger.Info("jobs listed successfully", - slog.Int("total", stats.TotalJobs), + slog.Int("total", totalJobs), slog.Int("displayed", len(jobs)), slog.String("status_filter", statusFilter), slog.Int("limit", limitFlag), @@ -203,7 +224,6 @@ Job status is computed in real-time from append-only status events.`, func init() { clientJobCmd.AddCommand(clientJobListCmd) - // Add filtering flags clientJobListCmd.Flags(). String("status", "", "Filter jobs by status (submitted, processing, completed, failed, partial_failure)") clientJobListCmd.Flags().Int("limit", 10, "Limit number of jobs displayed (0 for no limit)") diff --git a/cmd/client_job_run.go b/cmd/client_job_run.go index a649499d..a6aa4fad 100644 --- a/cmd/client_job_run.go +++ b/cmd/client_job_run.go @@ -23,12 +23,16 @@ package cmd import ( "context" "encoding/json" + "fmt" "io" "log/slog" + "net/http" "os" "time" "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" ) // clientJobRunCmd represents the clientJobRun command. @@ -38,7 +42,6 @@ var clientJobRunCmd = &cobra.Command{ Long: `Submits a job request and polls for completion, then displays the results. This combines job submission and retrieval into a single command for convenience.`, Run: func(cmd *cobra.Command, _ []string) { - // Get flags timeoutSeconds, _ := cmd.Flags().GetInt("timeout") pollSeconds, _ := cmd.Flags().GetInt("poll-interval") jsonFilePath, _ := cmd.Flags().GetString("json-file") @@ -47,12 +50,10 @@ This combines job submission and retrieval into a single command for convenience targetHostname = "_any" } - // Create context with timeout timeout := time.Duration(timeoutSeconds) * time.Second ctx, cancel := context.WithTimeout(cmd.Context(), timeout) defer cancel() - // Open and read the JSON file file, err := os.Open(jsonFilePath) if err != nil { logFatal("failed to open file", err) @@ -64,20 +65,34 @@ This combines job submission and retrieval into a single command for convenience logFatal("failed to read file", err) } - // Parse the JSON operation var operationData map[string]interface{} if err := json.Unmarshal(fileContents, &operationData); err != nil { logFatal("failed to parse JSON operation file", err) } + jobHandler := handler.(client.JobHandler) + // Submit the job - result, err := jobClient.CreateJob(ctx, operationData, targetHostname) + resp, err := jobHandler.PostJob(ctx, operationData, targetHostname) if err != nil { logger.Error("failed to submit job", slog.String("error", err.Error())) return } - jobID := result.JobID + if resp.StatusCode() != http.StatusCreated { + logger.Error("failed to submit job", + slog.Int("status_code", resp.StatusCode()), + slog.String("body", string(resp.Body)), + ) + return + } + + if resp.JSON201 == nil { + logger.Error("failed to submit job: nil response") + return + } + + jobID := resp.JSON201.JobId logger.Debug("job submitted", slog.String("job_id", jobID)) // Poll for completion @@ -85,12 +100,10 @@ This combines job submission and retrieval into a single command for convenience ticker := time.NewTicker(pollInterval) defer ticker.Stop() - // Check immediately - if checkJobComplete(ctx, jobID) { + if checkJobComplete(ctx, jobHandler, jobID) { return } - // Poll until timeout or completion for { select { case <-ctx.Done(): @@ -100,7 +113,7 @@ This combines job submission and retrieval into a single command for convenience ) return case <-ticker.C: - if checkJobComplete(ctx, jobID) { + if checkJobComplete(ctx, jobHandler, jobID) { return } } @@ -110,9 +123,10 @@ This combines job submission and retrieval into a single command for convenience func checkJobComplete( ctx context.Context, + jobHandler client.JobHandler, jobID string, ) bool { - jobInfo, err := jobClient.GetJobStatus(ctx, jobID) + resp, err := jobHandler.GetJobByID(ctx, jobID) if err != nil { logger.Error("failed to get job status", slog.String("job_id", jobID), @@ -121,21 +135,32 @@ func checkJobComplete( return false } + if resp.StatusCode() != http.StatusOK || resp.JSON200 == nil { + logger.Error("failed to get job status", + slog.String("job_id", jobID), + slog.Int("status_code", resp.StatusCode()), + ) + return false + } + + status := safeString(resp.JSON200.Status) logger.Debug("job status check", slog.String("job_id", jobID), - slog.String("status", jobInfo.Status), + slog.String("status", status), ) - // Check if job is complete (including partial_failure) - if jobInfo.Status == "completed" || jobInfo.Status == "failed" || - jobInfo.Status == "partial_failure" { + if status == "completed" || status == "failed" || status == "partial_failure" { logger.Debug("job finished", slog.String("job_id", jobID), - slog.String("status", jobInfo.Status), + slog.String("status", status), ) - // Display results - displayJobDetails(jobInfo) + if jsonOutput { + fmt.Println(string(resp.Body)) + return true + } + + displayJobDetailResponse(resp.JSON200) return true } diff --git a/cmd/client_job_status.go b/cmd/client_job_status.go index 630ced01..a2ce3c44 100644 --- a/cmd/client_job_status.go +++ b/cmd/client_job_status.go @@ -24,13 +24,15 @@ import ( "context" "encoding/json" "fmt" - "log/slog" + "net/http" "strings" "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" ) type jobsModel struct { @@ -83,7 +85,6 @@ func (m jobsModel) Update( m.isLoading = false return m, m.tickCmd() case time.Time: - // timer ticks, fetch new jobs status return m, fetchJobsCmd() } return m, nil @@ -102,7 +103,6 @@ func (m jobsModel) View() string { title := titleStyle.Render("Jobs Queue Status") - // Apply styling to the status text with colored keys/values styledStatus := styleStatusText(m.jobsStatus) lastUpdated := timeStyle.Render( @@ -119,27 +119,23 @@ func styleStatusText( statusText string, ) string { var ( - keyStyle = lipgloss.NewStyle().Foreground(gray) // Gray for all keys - valueStyle = lipgloss.NewStyle().Foreground(teal) // Soft teal for values - sectionStyle = lipgloss.NewStyle().Foreground(gray) // Gray for section headers + keyStyle = lipgloss.NewStyle().Foreground(gray) + valueStyle = lipgloss.NewStyle().Foreground(teal) + sectionStyle = lipgloss.NewStyle().Foreground(gray) ) lines := strings.Split(statusText, "\n") var styledLines []string for _, line := range lines { - // Check if this is a section header (no indentation, ends with colon, no value after) if strings.HasSuffix(strings.TrimSpace(line), ":") && !strings.HasPrefix(line, " ") { - // This is a section header like "Jobs Queue Status:" or "Operation Types:" styledLines = append(styledLines, sectionStyle.Render(line)) } else if strings.Contains(line, ":") { - // Split on the first colon to separate key and value parts := strings.SplitN(line, ":", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) - // Preserve indentation indent := "" if strings.HasPrefix(line, " ") { indent = " " @@ -151,7 +147,6 @@ func styleStatusText( styledLines = append(styledLines, line) } } else { - // No colon, just render as is styledLines = append(styledLines, line) } } @@ -160,29 +155,49 @@ func styleStatusText( } func fetchJobsStatus() string { - stats, err := jobClient.GetQueueStats(context.Background()) + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.GetJobQueueStats(context.Background()) if err != nil { return fmt.Sprintf("Error fetching jobs: %v", err) } - if stats.TotalJobs == 0 { + if resp.StatusCode() != http.StatusOK { + return fmt.Sprintf("Error fetching jobs: HTTP %d", resp.StatusCode()) + } + + stats := resp.JSON200 + if stats == nil { + return "Error fetching jobs: nil response" + } + + totalJobs := 0 + if stats.TotalJobs != nil { + totalJobs = *stats.TotalJobs + } + + if totalJobs == 0 { return "Job queue is empty (0 jobs total)" } - // Build status display + statusCounts := map[string]int{} + if stats.StatusCounts != nil { + statusCounts = *stats.StatusCounts + } + statusDisplay := "Jobs Queue Status:\n" - statusDisplay += fmt.Sprintf(" Total Jobs: %d\n", stats.TotalJobs) - statusDisplay += fmt.Sprintf(" Unprocessed: %d\n", stats.StatusCounts["unprocessed"]) - statusDisplay += fmt.Sprintf(" Processing: %d\n", stats.StatusCounts["processing"]) - statusDisplay += fmt.Sprintf(" Completed: %d\n", stats.StatusCounts["completed"]) - statusDisplay += fmt.Sprintf(" Failed: %d\n", stats.StatusCounts["failed"]) - if stats.DLQCount > 0 { - statusDisplay += fmt.Sprintf(" Dead Letter Queue: %d\n", stats.DLQCount) + statusDisplay += fmt.Sprintf(" Total Jobs: %d\n", totalJobs) + statusDisplay += fmt.Sprintf(" Unprocessed: %d\n", statusCounts["unprocessed"]) + statusDisplay += fmt.Sprintf(" Processing: %d\n", statusCounts["processing"]) + statusDisplay += fmt.Sprintf(" Completed: %d\n", statusCounts["completed"]) + statusDisplay += fmt.Sprintf(" Failed: %d\n", statusCounts["failed"]) + + if stats.DlqCount != nil && *stats.DlqCount > 0 { + statusDisplay += fmt.Sprintf(" Dead Letter Queue: %d\n", *stats.DlqCount) } - if len(stats.OperationCounts) > 0 { + if stats.OperationCounts != nil && len(*stats.OperationCounts) > 0 { statusDisplay += "\nOperation Types:\n" - for opType, count := range stats.OperationCounts { + for opType, count := range *stats.OperationCounts { statusDisplay += fmt.Sprintf(" %s: %d\n", opType, count) } } @@ -191,7 +206,8 @@ func fetchJobsStatus() string { } func fetchJobsStatusJSON() string { - stats, err := jobClient.GetQueueStats(context.Background()) + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.GetJobQueueStats(context.Background()) if err != nil { errorResult := map[string]interface{}{ "error": fmt.Sprintf("Error fetching jobs: %v", err), @@ -200,8 +216,15 @@ func fetchJobsStatusJSON() string { return string(resultJSON) } - resultJSON, _ := json.Marshal(stats) - return string(resultJSON) + if resp.StatusCode() != http.StatusOK { + errorResult := map[string]interface{}{ + "error": fmt.Sprintf("HTTP %d", resp.StatusCode()), + } + resultJSON, _ := json.Marshal(errorResult) + return string(resultJSON) + } + + return string(resp.Body) } // clientJobStatusCmd represents the clientJobsStatus command. @@ -209,24 +232,20 @@ var clientJobStatusCmd = &cobra.Command{ Use: "status", Short: "Display the jobs queue status", Long: `Displays the jobs queue status with automatic updates. -Shows job counts by status (unprocessed/processing/completed/failed) +Shows job counts by status (unprocessed/processing/completed/failed) and operation types with live refresh.`, Run: func(cmd *cobra.Command, _ []string) { pollIntervalSeconds, _ := cmd.Flags().GetInt("poll-interval-seconds") - // Check if running in non-interactive mode (JSON output or no TTY) if jsonOutput { - // Get status once and output as JSON status := fetchJobsStatusJSON() - logger.Info("jobs status", slog.String("response", status)) + fmt.Println(status) return } - // Run interactive TUI p := tea.NewProgram(initialJobsModel(pollIntervalSeconds)) _, err := p.Run() if err != nil { - // Fallback to non-interactive mode if TUI fails status := fetchJobsStatus() fmt.Println(status) } diff --git a/cmd/client_job_workers.go b/cmd/client_job_workers.go new file mode 100644 index 00000000..ea97c488 --- /dev/null +++ b/cmd/client_job_workers.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// clientJobWorkersCmd represents the clientJobWorkers command. +var clientJobWorkersCmd = &cobra.Command{ + Use: "workers", + Short: "The workers subcommand", +} + +func init() { + clientJobCmd.AddCommand(clientJobWorkersCmd) +} diff --git a/cmd/client_job_workers_list.go b/cmd/client_job_workers_list.go new file mode 100644 index 00000000..0392a9e0 --- /dev/null +++ b/cmd/client_job_workers_list.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "fmt" + "net/http" + + "github.com/spf13/cobra" + + "github.com/retr0h/osapi/internal/client" +) + +// clientJobWorkersListCmd represents the clientJobWorkersList command. +var clientJobWorkersListCmd = &cobra.Command{ + Use: "list", + Short: "List active workers", + Long: `Discover all active workers by broadcasting a hostname query +and collecting responses. Shows each worker's hostname.`, + Run: func(cmd *cobra.Command, _ []string) { + ctx := cmd.Context() + + jobHandler := handler.(client.JobHandler) + resp, err := jobHandler.GetJobWorkers(ctx) + if err != nil { + logFatal("failed to list workers", err) + } + + switch resp.StatusCode() { + case http.StatusOK: + if jsonOutput { + fmt.Println(string(resp.Body)) + return + } + + if resp.JSON200 == nil { + logFatal("failed response", fmt.Errorf("workers response was nil")) + } + + workers := resp.JSON200.Workers + if len(workers) == 0 { + fmt.Println("No active workers found.") + return + } + + rows := make([][]string, 0, len(workers)) + for _, w := range workers { + rows = append(rows, []string{w.Hostname}) + } + + sections := []section{ + { + Title: fmt.Sprintf("Active Workers (%d)", resp.JSON200.Total), + Headers: []string{"HOSTNAME"}, + Rows: rows, + }, + } + printStyledTable(sections) + case http.StatusUnauthorized: + handleAuthError(resp.JSON401, resp.StatusCode(), logger) + case http.StatusForbidden: + handleAuthError(resp.JSON403, resp.StatusCode(), logger) + default: + handleUnknownError(resp.JSON500, resp.StatusCode(), logger) + } + }, +} + +func init() { + clientJobWorkersCmd.AddCommand(clientJobWorkersListCmd) +} diff --git a/cmd/client_network_dns_get.go b/cmd/client_network_dns_get.go index b1bb516a..7e3ee3fc 100644 --- a/cmd/client_network_dns_get.go +++ b/cmd/client_network_dns_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "log/slog" "net/http" "github.com/spf13/cobra" @@ -38,10 +37,11 @@ var clientNetworkDNSGetCmd = &cobra.Command{ `, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") interfaceName, _ := cmd.Flags().GetString("interface-name") networkHandler := handler.(client.NetworkHandler) - resp, err := networkHandler.GetNetworkDNSByInterface(ctx, interfaceName) + resp, err := networkHandler.GetNetworkDNSByInterface(ctx, host, interfaceName) if err != nil { logFatal("failed to get network dns endpoint", err) } @@ -49,10 +49,7 @@ var clientNetworkDNSGetCmd = &cobra.Command{ switch resp.StatusCode() { case http.StatusOK: if jsonOutput { - logger.Info( - "network dns get", - slog.String("response", string(resp.Body)), - ) + fmt.Println(string(resp.Body)) return } diff --git a/cmd/client_network_dns_update.go b/cmd/client_network_dns_update.go index 657faae6..9fbd898b 100644 --- a/cmd/client_network_dns_update.go +++ b/cmd/client_network_dns_update.go @@ -21,6 +21,7 @@ package cmd import ( + "fmt" "log/slog" "net/http" "strings" @@ -38,13 +39,24 @@ var clientNetworkDNSUpdateCmd = &cobra.Command{ `, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") servers, _ := cmd.Flags().GetStringSlice("servers") searchDomains, _ := cmd.Flags().GetStringSlice("search-domains") interfaceName, _ := cmd.Flags().GetString("interface-name") + if host == "_all" { + fmt.Print("This will modify DNS on ALL hosts. Continue? [y/N] ") + var confirm string + if _, err := fmt.Scanln(&confirm); err != nil || (confirm != "y" && confirm != "Y") { + fmt.Println("Aborted.") + return + } + } + networkHandler := handler.(client.NetworkHandler) resp, err := networkHandler.PutNetworkDNS( ctx, + host, servers, searchDomains, interfaceName, diff --git a/cmd/client_network_ping.go b/cmd/client_network_ping.go index 5460f6da..ddb566ba 100644 --- a/cmd/client_network_ping.go +++ b/cmd/client_network_ping.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "log/slog" "net/http" "github.com/spf13/cobra" @@ -38,10 +37,11 @@ var clientNetworkPingCmd = &cobra.Command{ `, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") address, _ := cmd.Flags().GetString("address") networkHandler := handler.(client.NetworkHandler) - resp, err := networkHandler.PostNetworkPing(ctx, address) + resp, err := networkHandler.PostNetworkPing(ctx, host, address) if err != nil { logFatal("failed to post network ping endpoint", err) } @@ -49,10 +49,7 @@ var clientNetworkPingCmd = &cobra.Command{ switch resp.StatusCode() { case http.StatusOK: if jsonOutput { - logger.Info( - "network ping", - slog.String("response", string(resp.Body)), - ) + fmt.Println(string(resp.Body)) return } diff --git a/cmd/client_system_hostname_get.go b/cmd/client_system_hostname_get.go index c9a6762a..c314cea3 100644 --- a/cmd/client_system_hostname_get.go +++ b/cmd/client_system_hostname_get.go @@ -22,7 +22,6 @@ package cmd import ( "fmt" - "log/slog" "net/http" "github.com/spf13/cobra" @@ -38,8 +37,9 @@ var clientSystemHostnameGetCmd = &cobra.Command{ `, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") systemHandler := handler.(client.SystemHandler) - resp, err := systemHandler.GetSystemHostname(ctx) + resp, err := systemHandler.GetSystemHostname(ctx, host) if err != nil { logFatal("failed to get system status endpoint", err) } @@ -47,10 +47,7 @@ var clientSystemHostnameGetCmd = &cobra.Command{ switch resp.StatusCode() { case http.StatusOK: if jsonOutput { - logger.Info( - "system hostname", - slog.String("response", string(resp.Body)), - ) + fmt.Println(string(resp.Body)) return } diff --git a/cmd/client_system_status_get.go b/cmd/client_system_status_get.go index 6e92983e..dda39c06 100644 --- a/cmd/client_system_status_get.go +++ b/cmd/client_system_status_get.go @@ -21,13 +21,14 @@ package cmd import ( + "encoding/json" "fmt" - "log/slog" "net/http" "github.com/spf13/cobra" "github.com/retr0h/osapi/internal/client" + "github.com/retr0h/osapi/internal/client/gen" ) // clientSystemStatusGetCmd represents the clientSystemStatusGet command. @@ -38,8 +39,9 @@ var clientSystemStatusGetCmd = &cobra.Command{ `, Run: func(cmd *cobra.Command, _ []string) { ctx := cmd.Context() + host, _ := cmd.Flags().GetString("target") systemHandler := handler.(client.SystemHandler) - resp, err := systemHandler.GetSystemStatus(ctx) + resp, err := systemHandler.GetSystemStatus(ctx, host) if err != nil { logFatal("failed to get system status endpoint", err) } @@ -47,59 +49,20 @@ var clientSystemStatusGetCmd = &cobra.Command{ switch resp.StatusCode() { case http.StatusOK: if jsonOutput { - logger.Info( - "system status", - slog.String("response", string(resp.Body)), - ) + fmt.Println(string(resp.Body)) return } - if resp.JSON200 == nil { - logFatal("failed response", fmt.Errorf("system data response was nil")) + if host == "_all" { + displayMultiSystemStatus(resp.Body) + return } - systemData := map[string]interface{}{ - "Load Average (1m, 5m, 15m)": fmt.Sprintf( - "%.2f, %.2f, %.2f", - resp.JSON200.LoadAverage.N1min, - resp.JSON200.LoadAverage.N5min, - resp.JSON200.LoadAverage.N15min, - ), - "Memory": fmt.Sprintf( - "%d GB used / %d GB total / %d GB free", - resp.JSON200.Memory.Used/1024/1024/1024, - resp.JSON200.Memory.Total/1024/1024/1024, - resp.JSON200.Memory.Free/1024/1024/1024, - ), - "OS": fmt.Sprintf( - "%s %s", - resp.JSON200.OsInfo.Distribution, - resp.JSON200.OsInfo.Version, - ), - } - printStyledMap(systemData) - - diskRows := [][]string{} - - if resp.JSON200.Disks != nil { - for _, disk := range resp.JSON200.Disks { - diskRows = append(diskRows, []string{ - disk.Name, - fmt.Sprintf("%d GB", disk.Total/1024/1024/1024), - fmt.Sprintf("%d GB", disk.Used/1024/1024/1024), - fmt.Sprintf("%d GB", disk.Free/1024/1024/1024), - }) - } + if resp.JSON200 == nil { + logFatal("failed response", fmt.Errorf("system data response was nil")) } - sections := []section{ - { - Title: "Disks", - Headers: []string{"DISK NAME", "TOTAL", "USED", "FREE"}, - Rows: diskRows, - }, - } - printStyledTable(sections) + displaySingleSystemStatus(resp.JSON200) case http.StatusUnauthorized: handleAuthError(resp.JSON401, resp.StatusCode(), logger) @@ -111,6 +74,88 @@ var clientSystemStatusGetCmd = &cobra.Command{ }, } +// displaySingleSystemStatus renders a single system status response. +func displaySingleSystemStatus( + data *gen.SystemStatusResponse, +) { + systemData := map[string]interface{}{ + "Load Average (1m, 5m, 15m)": fmt.Sprintf( + "%.2f, %.2f, %.2f", + data.LoadAverage.N1min, + data.LoadAverage.N5min, + data.LoadAverage.N15min, + ), + "Memory": fmt.Sprintf( + "%d GB used / %d GB total / %d GB free", + data.Memory.Used/1024/1024/1024, + data.Memory.Total/1024/1024/1024, + data.Memory.Free/1024/1024/1024, + ), + "OS": fmt.Sprintf( + "%s %s", + data.OsInfo.Distribution, + data.OsInfo.Version, + ), + } + printStyledMap(systemData) + + diskRows := [][]string{} + + if data.Disks != nil { + for _, disk := range data.Disks { + diskRows = append(diskRows, []string{ + disk.Name, + fmt.Sprintf("%d GB", disk.Total/1024/1024/1024), + fmt.Sprintf("%d GB", disk.Used/1024/1024/1024), + fmt.Sprintf("%d GB", disk.Free/1024/1024/1024), + }) + } + } + + sections := []section{ + { + Title: "Disks", + Headers: []string{"DISK NAME", "TOTAL", "USED", "FREE"}, + Rows: diskRows, + }, + } + printStyledTable(sections) +} + +// displayMultiSystemStatus renders multiple system status responses from _all broadcast. +func displayMultiSystemStatus( + body []byte, +) { + var multiResp struct { + Results []gen.SystemStatusResponse `json:"results"` + } + if err := json.Unmarshal(body, &multiResp); err != nil { + logFatal("failed to parse multi-host response", err) + } + + rows := make([][]string, 0, len(multiResp.Results)) + for _, s := range multiResp.Results { + rows = append(rows, []string{ + s.Hostname, + s.Uptime, + fmt.Sprintf("%.2f", s.LoadAverage.N1min), + fmt.Sprintf( + "%d GB / %d GB", + s.Memory.Used/1024/1024/1024, + s.Memory.Total/1024/1024/1024, + ), + }) + } + + sections := []section{ + { + Headers: []string{"HOSTNAME", "UPTIME", "LOAD (1m)", "MEMORY USED"}, + Rows: rows, + }, + } + printStyledTable(sections) +} + func init() { clientSystemCmd.AddCommand(clientSystemStatusGetCmd) } diff --git a/cmd/root.go b/cmd/root.go index e20a3a2c..6d6a7641 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -130,6 +130,6 @@ func initLogger() { ) if jsonOutput { - logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) + logger = slog.New(slog.NewJSONHandler(os.Stderr, nil)) } } diff --git a/cmd/ui.go b/cmd/ui.go index 9ad8971f..4b940505 100644 --- a/cmd/ui.go +++ b/cmd/ui.go @@ -32,7 +32,6 @@ import ( "golang.org/x/term" "github.com/retr0h/osapi/internal/client/gen" - "github.com/retr0h/osapi/internal/job" ) // TODO(retr0h): consider moving out of global scope @@ -294,55 +293,50 @@ func handleUnknownError( ) } -// displayJobDetails displays detailed job information in a consistent format. +// displayJobDetailResponse displays detailed job information from a REST API response. // Used by both job get and job run commands. -func displayJobDetails( - jobInfo *job.QueuedJob, +func displayJobDetailResponse( + resp *gen.JobDetailResponse, ) { - if jsonOutput { - resultJSON, _ := json.Marshal(jobInfo) - logger.Info("job", slog.String("response", string(resultJSON))) - return - } - - // Display job details with enhanced status information + // Display job metadata jobData := map[string]interface{}{ - "Job ID": jobInfo.ID, - "Status": jobInfo.Status, - "Created": jobInfo.Created, + "Job ID": safeString(resp.Id), + "Status": safeString(resp.Status), } - // Add error field if present - if jobInfo.Error != "" { - jobData["Error"] = jobInfo.Error + if resp.Created != nil { + jobData["Created"] = *resp.Created } - - // Add subject if present - if jobInfo.Subject != "" { - jobData["Subject"] = jobInfo.Subject + if resp.Error != nil && *resp.Error != "" { + jobData["Error"] = *resp.Error + } + if resp.Hostname != nil && *resp.Hostname != "" { + jobData["Hostname"] = *resp.Hostname + } + if resp.UpdatedAt != nil && *resp.UpdatedAt != "" { + jobData["Updated At"] = *resp.UpdatedAt } - // Add worker summary - if len(jobInfo.WorkerStates) > 0 { + // Add worker summary from worker_states + if resp.WorkerStates != nil && len(*resp.WorkerStates) > 0 { completed := 0 failed := 0 processing := 0 - acknowledged := 0 - - for _, state := range jobInfo.WorkerStates { - switch state.Status { - case "completed": - completed++ - case "failed": - failed++ - case "started": - processing++ - case "acknowledged": - acknowledged++ + + for _, state := range *resp.WorkerStates { + if state.Status != nil { + switch *state.Status { + case "completed": + completed++ + case "failed": + failed++ + case "started": + processing++ + } } } - total := len(jobInfo.WorkerStates) + total := len(*resp.WorkerStates) if total > 1 { jobData["Workers"] = fmt.Sprintf( "%d total (%d completed, %d failed, %d processing)", @@ -356,12 +350,11 @@ func displayJobDetails( printStyledMap(jobData) - // Collect content for sections to ensure consistent table widths var sections []section // Display the operation request - if jobInfo.Operation != nil { - jobOperationJSON, _ := json.MarshalIndent(jobInfo.Operation, "", " ") + if resp.Operation != nil { + jobOperationJSON, _ := json.MarshalIndent(*resp.Operation, "", " ") operationRows := [][]string{{string(jobOperationJSON)}} sections = append(sections, section{ Title: "Job Request", @@ -370,85 +363,67 @@ func displayJobDetails( }) } - // Display timeline if available - if len(jobInfo.Timeline) > 0 { - timelineRows := [][]string{} - for _, event := range jobInfo.Timeline { - row := []string{ - event.Timestamp.Format("15:04:05 MST"), - event.Event, - event.Hostname, - event.Message, + // Display worker responses (for broadcast jobs) + if resp.Responses != nil && len(*resp.Responses) > 0 { + responseRows := make([][]string, 0, len(*resp.Responses)) + for hostname, response := range *resp.Responses { + status := safeString(response.Status) + errMsg := "" + if response.Error != nil { + errMsg = *response.Error } - if event.Error != "" { - row = append(row, event.Error) + + var dataStr string + if response.Data != nil { + dataJSON, _ := json.MarshalIndent(response.Data, "", " ") + dataStr = string(dataJSON) } else { - row = append(row, "") + dataStr = "(no data)" } - timelineRows = append(timelineRows, row) + + row := []string{hostname, status, dataStr, errMsg} + responseRows = append(responseRows, row) } + sections = append(sections, section{ - Title: "Timeline", - Headers: []string{"TIME", "EVENT", "HOSTNAME", "MESSAGE", "ERROR"}, - Rows: timelineRows, + Title: "Worker Responses", + Headers: []string{"HOSTNAME", "STATUS", "DATA", "ERROR"}, + Rows: responseRows, }) } - // Display responses (the actual job results) - if len(jobInfo.Responses) > 0 { - responseRows := [][]string{} - for hostname, response := range jobInfo.Responses { - // Format the response data with nice indentation - var responseData string - if len(response.Data) > 0 { - var data interface{} - if err := json.Unmarshal(response.Data, &data); err == nil { - // Use pretty printing with 2-space indentation - dataJSON, _ := json.MarshalIndent(data, "", " ") - responseData = string(dataJSON) - } else { - responseData = string(response.Data) - } - } else { - responseData = "(no data)" + // Display worker states (for broadcast jobs) + if resp.WorkerStates != nil && len(*resp.WorkerStates) > 0 { + stateRows := make([][]string, 0, len(*resp.WorkerStates)) + for hostname, state := range *resp.WorkerStates { + status := safeString(state.Status) + duration := safeString(state.Duration) + errMsg := "" + if state.Error != nil { + errMsg = *state.Error } - row := []string{ - hostname, - string(response.Status), - response.Timestamp.Format("15:04:05 MST"), - responseData, - } - if response.Error != "" { - row = append(row, response.Error) - } else { - row = append(row, "") - } - responseRows = append(responseRows, row) + stateRows = append(stateRows, []string{hostname, status, duration, errMsg}) } sections = append(sections, section{ - Title: "Worker Responses", - Headers: []string{"HOSTNAME", "STATUS", "TIME", "DATA", "ERROR"}, - Rows: responseRows, + Title: "Worker States", + Headers: []string{"HOSTNAME", "STATUS", "DURATION", "ERROR"}, + Rows: stateRows, }) } - // Display results if completed and available (legacy support) - if jobInfo.Status == "completed" && len(jobInfo.Result) > 0 { - var result interface{} - if err := json.Unmarshal(jobInfo.Result, &result); err == nil { - resultJSON, _ := json.MarshalIndent(result, "", " ") - resultRows := [][]string{{string(resultJSON)}} - sections = append(sections, section{ - Title: "Job Response (Legacy)", - Headers: []string{"DATA"}, - Rows: resultRows, - }) - } + // Display result if completed + if resp.Result != nil { + resultJSON, _ := json.MarshalIndent(resp.Result, "", " ") + resultRows := [][]string{{string(resultJSON)}} + sections = append(sections, section{ + Title: "Job Result", + Headers: []string{"DATA"}, + Rows: resultRows, + }) } - // Print each section individually to ensure consistent formatting for _, sec := range sections { printStyledTable([]section{sec}) } diff --git a/docs/docs/sidebar/usage/cli/client/job/add.md b/docs/docs/sidebar/usage/cli/client/job/add.md index 14a950c2..ba906e8e 100644 --- a/docs/docs/sidebar/usage/cli/client/job/add.md +++ b/docs/docs/sidebar/usage/cli/client/job/add.md @@ -1,6 +1,6 @@ # Add -Add a job to the NATS job queue: +Add a job to the queue: ```bash $ osapi client job add \ diff --git a/docs/docs/sidebar/usage/cli/client/job/delete.md b/docs/docs/sidebar/usage/cli/client/job/delete.md index 09f1f854..f0942cee 100644 --- a/docs/docs/sidebar/usage/cli/client/job/delete.md +++ b/docs/docs/sidebar/usage/cli/client/job/delete.md @@ -1,6 +1,6 @@ # Delete -Delete a job from the NATS KV store: +Delete a job: ```bash $ osapi client job delete --job-id 550e8400-e29b-41d4-a716-446655440000 diff --git a/docs/docs/sidebar/usage/cli/client/job/get.md b/docs/docs/sidebar/usage/cli/client/job/get.md index 3d17a956..39156765 100644 --- a/docs/docs/sidebar/usage/cli/client/job/get.md +++ b/docs/docs/sidebar/usage/cli/client/job/get.md @@ -1,6 +1,6 @@ # Get -Get job details and status from the NATS KV store: +Get job details and status: ```bash $ osapi client job get --job-id 550e8400-e29b-41d4-a716-446655440000 @@ -19,5 +19,4 @@ $ osapi client job get --job-id 550e8400-e29b-41d4-a716-446655440000 | ---------- | ------------------ | -------- | | `--job-id` | Job ID to retrieve | required | -Job status is computed in real-time from append-only status events in the KV -store. +Job status is retrieved from the API and reflects the current state of the job. diff --git a/docs/docs/sidebar/usage/cli/client/job/job.md b/docs/docs/sidebar/usage/cli/client/job/job.md index 603683ca..613d12fe 100644 --- a/docs/docs/sidebar/usage/cli/client/job/job.md +++ b/docs/docs/sidebar/usage/cli/client/job/job.md @@ -1,7 +1,6 @@ # Job -CLI to manage job queue resources. Interacts directly with the NATS job queue -for job submission, monitoring, and management. +Manage job queue resources via the REST API. import DocCardList from '@theme/DocCardList'; diff --git a/docs/docs/sidebar/usage/cli/client/job/list.md b/docs/docs/sidebar/usage/cli/client/job/list.md index dbf4c3dc..f61b7ca9 100644 --- a/docs/docs/sidebar/usage/cli/client/job/list.md +++ b/docs/docs/sidebar/usage/cli/client/job/list.md @@ -1,6 +1,6 @@ # List -List jobs from the NATS KV store: +List jobs: ```bash $ osapi client job list diff --git a/docs/docs/sidebar/usage/cli/client/job/workers/list.md b/docs/docs/sidebar/usage/cli/client/job/workers/list.md new file mode 100644 index 00000000..55107f9e --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/job/workers/list.md @@ -0,0 +1,20 @@ +# List + +List active workers in the fleet: + +```bash +$ osapi client job workers list + + + Active Workers (2): + + ┏━━━━━━━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ + ┣━━━━━━━━━━━━━━━━━━━━┫ + ┃ worker-node-1 ┃ + ┃ worker-node-2 ┃ + ┗━━━━━━━━━━━━━━━━━━━━┛ +``` + +Discovers all active workers by broadcasting a hostname query and collecting +responses. diff --git a/docs/docs/sidebar/usage/cli/client/job/workers/workers.md b/docs/docs/sidebar/usage/cli/client/job/workers/workers.md new file mode 100644 index 00000000..50315ec8 --- /dev/null +++ b/docs/docs/sidebar/usage/cli/client/job/workers/workers.md @@ -0,0 +1,7 @@ +# Workers + +CLI to manage job worker resources. + +import DocCardList from '@theme/DocCardList'; + + diff --git a/internal/api/job/gen/api.yaml b/internal/api/job/gen/api.yaml index 17f93355..145f69f0 100644 --- a/internal/api/job/gen/api.yaml +++ b/internal/api/job/gen/api.yaml @@ -97,6 +97,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ListJobsResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' '401': description: Unauthorized - API key required content: @@ -151,6 +157,41 @@ paths: schema: $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' + /job/workers: + get: + summary: List active workers + description: Discover all active workers in the fleet by broadcasting a hostname query. + tags: + - job_operations + security: + - BearerAuth: + - read + responses: + '200': + description: List of active workers. + content: + application/json: + schema: + $ref: '#/components/schemas/ListWorkersResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' + '500': + description: Error discovering workers. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' + /job/{id}: get: summary: Get job detail @@ -174,6 +215,12 @@ paths: application/json: schema: $ref: '#/components/schemas/JobDetailResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' '401': description: Unauthorized - API key required content: @@ -217,6 +264,12 @@ paths: responses: '204': description: Job deleted successfully. No content returned. + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' '401': description: Unauthorized - API key required content: @@ -356,6 +409,29 @@ components: duration: type: string + ListWorkersResponse: + type: object + properties: + workers: + type: array + items: + $ref: '#/components/schemas/WorkerInfo' + total: + type: integer + description: Total number of active workers. + required: + - workers + - total + + WorkerInfo: + type: object + properties: + hostname: + type: string + description: The hostname of the worker. + required: + - hostname + QueueStatsResponse: type: object properties: diff --git a/internal/api/job/gen/job.gen.go b/internal/api/job/gen/job.gen.go index 8bb8abea..1c170553 100644 --- a/internal/api/job/gen/job.gen.go +++ b/internal/api/job/gen/job.gen.go @@ -97,6 +97,13 @@ type ListJobsResponse struct { TotalItems *int `json:"total_items,omitempty"` } +// ListWorkersResponse defines model for ListWorkersResponse. +type ListWorkersResponse struct { + // Total Total number of active workers. + Total int `json:"total"` + Workers []WorkerInfo `json:"workers"` +} + // QueueStatsResponse defines model for QueueStatsResponse. type QueueStatsResponse struct { // DlqCount Number of jobs in the dead letter queue. @@ -112,6 +119,12 @@ type QueueStatsResponse struct { TotalJobs *int `json:"total_jobs,omitempty"` } +// WorkerInfo defines model for WorkerInfo. +type WorkerInfo struct { + // Hostname The hostname of the worker. + Hostname string `json:"hostname"` +} + // GetJobParams defines parameters for GetJob. type GetJobParams struct { // Status Filter jobs by status (e.g., unprocessed, processing, completed, failed). @@ -132,6 +145,9 @@ type ServerInterface interface { // Get queue statistics // (GET /job/status) GetJobStatus(ctx echo.Context) error + // List active workers + // (GET /job/workers) + GetJobWorkers(ctx echo.Context) error // Delete a job // (DELETE /job/{id}) DeleteJobByID(ctx echo.Context, id string) error @@ -187,6 +203,17 @@ func (w *ServerInterfaceWrapper) GetJobStatus(ctx echo.Context) error { return err } +// GetJobWorkers converts echo context to params. +func (w *ServerInterfaceWrapper) GetJobWorkers(ctx echo.Context) error { + var err error + + ctx.Set(BearerAuthScopes, []string{"read"}) + + // Invoke the callback with all the unmarshaled arguments + err = w.Handler.GetJobWorkers(ctx) + return err +} + // DeleteJobByID converts echo context to params. func (w *ServerInterfaceWrapper) DeleteJobByID(ctx echo.Context) error { var err error @@ -254,6 +281,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL router.GET(baseURL+"/job", wrapper.GetJob) router.POST(baseURL+"/job", wrapper.PostJob) router.GET(baseURL+"/job/status", wrapper.GetJobStatus) + router.GET(baseURL+"/job/workers", wrapper.GetJobWorkers) router.DELETE(baseURL+"/job/:id", wrapper.DeleteJobByID) router.GET(baseURL+"/job/:id", wrapper.GetJobByID) @@ -276,6 +304,15 @@ func (response GetJob200JSONResponse) VisitGetJobResponse(w http.ResponseWriter) return json.NewEncoder(w).Encode(response) } +type GetJob400JSONResponse externalRef0.ErrorResponse + +func (response GetJob400JSONResponse) VisitGetJobResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type GetJob401JSONResponse externalRef0.ErrorResponse func (response GetJob401JSONResponse) VisitGetJobResponse(w http.ResponseWriter) error { @@ -399,6 +436,49 @@ func (response GetJobStatus500JSONResponse) VisitGetJobStatusResponse(w http.Res return json.NewEncoder(w).Encode(response) } +type GetJobWorkersRequestObject struct { +} + +type GetJobWorkersResponseObject interface { + VisitGetJobWorkersResponse(w http.ResponseWriter) error +} + +type GetJobWorkers200JSONResponse ListWorkersResponse + +func (response GetJobWorkers200JSONResponse) VisitGetJobWorkersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type GetJobWorkers401JSONResponse externalRef0.ErrorResponse + +func (response GetJobWorkers401JSONResponse) VisitGetJobWorkersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(401) + + return json.NewEncoder(w).Encode(response) +} + +type GetJobWorkers403JSONResponse externalRef0.ErrorResponse + +func (response GetJobWorkers403JSONResponse) VisitGetJobWorkersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(403) + + return json.NewEncoder(w).Encode(response) +} + +type GetJobWorkers500JSONResponse externalRef0.ErrorResponse + +func (response GetJobWorkers500JSONResponse) VisitGetJobWorkersResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type DeleteJobByIDRequestObject struct { Id string `json:"id"` } @@ -415,6 +495,15 @@ func (response DeleteJobByID204Response) VisitDeleteJobByIDResponse(w http.Respo return nil } +type DeleteJobByID400JSONResponse externalRef0.ErrorResponse + +func (response DeleteJobByID400JSONResponse) VisitDeleteJobByIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type DeleteJobByID401JSONResponse externalRef0.ErrorResponse func (response DeleteJobByID401JSONResponse) VisitDeleteJobByIDResponse(w http.ResponseWriter) error { @@ -468,6 +557,15 @@ func (response GetJobByID200JSONResponse) VisitGetJobByIDResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetJobByID400JSONResponse externalRef0.ErrorResponse + +func (response GetJobByID400JSONResponse) VisitGetJobByIDResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type GetJobByID401JSONResponse externalRef0.ErrorResponse func (response GetJobByID401JSONResponse) VisitGetJobByIDResponse(w http.ResponseWriter) error { @@ -515,6 +613,9 @@ type StrictServerInterface interface { // Get queue statistics // (GET /job/status) GetJobStatus(ctx context.Context, request GetJobStatusRequestObject) (GetJobStatusResponseObject, error) + // List active workers + // (GET /job/workers) + GetJobWorkers(ctx context.Context, request GetJobWorkersRequestObject) (GetJobWorkersResponseObject, error) // Delete a job // (DELETE /job/{id}) DeleteJobByID(ctx context.Context, request DeleteJobByIDRequestObject) (DeleteJobByIDResponseObject, error) @@ -612,6 +713,29 @@ func (sh *strictHandler) GetJobStatus(ctx echo.Context) error { return nil } +// GetJobWorkers operation middleware +func (sh *strictHandler) GetJobWorkers(ctx echo.Context) error { + var request GetJobWorkersRequestObject + + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { + return sh.ssi.GetJobWorkers(ctx.Request().Context(), request.(GetJobWorkersRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetJobWorkers") + } + + response, err := handler(ctx, request) + + if err != nil { + return err + } else if validResponse, ok := response.(GetJobWorkersResponseObject); ok { + return validResponse.VisitGetJobWorkersResponse(ctx.Response()) + } else if response != nil { + return fmt.Errorf("unexpected response type: %T", response) + } + return nil +} + // DeleteJobByID operation middleware func (sh *strictHandler) DeleteJobByID(ctx echo.Context, id string) error { var request DeleteJobByIDRequestObject diff --git a/internal/api/job/job_delete.go b/internal/api/job/job_delete.go index dd46f30b..aaa78a0c 100644 --- a/internal/api/job/job_delete.go +++ b/internal/api/job/job_delete.go @@ -25,6 +25,7 @@ import ( "strings" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/validation" ) // DeleteJobByID deletes a specific job by its ID. @@ -32,6 +33,13 @@ func (j *Job) DeleteJobByID( ctx context.Context, request gen.DeleteJobByIDRequestObject, ) (gen.DeleteJobByIDResponseObject, error) { + id := struct { + ID string `validate:"required,uuid"` + }{ID: request.Id} + if errMsg, ok := validation.Struct(id); !ok { + return gen.DeleteJobByID400JSONResponse{Error: &errMsg}, nil + } + err := j.JobClient.DeleteJob(ctx, request.Id) if err != nil { errMsg := err.Error() diff --git a/internal/api/job/job_delete_public_test.go b/internal/api/job/job_delete_public_test.go index e10013d4..38fd3011 100644 --- a/internal/api/job/job_delete_public_test.go +++ b/internal/api/job/job_delete_public_test.go @@ -59,29 +59,57 @@ func (s *JobDeletePublicTestSuite) TestDeleteJobByID() { name string request gen.DeleteJobByIDRequestObject mockError error + expectMock bool validateFunc func(resp gen.DeleteJobByIDResponseObject) }{ { - name: "success", - request: gen.DeleteJobByIDRequestObject{Id: "job-1"}, + name: "validation error invalid uuid", + request: gen.DeleteJobByIDRequestObject{Id: "not-a-uuid"}, + expectMock: false, + validateFunc: func(resp gen.DeleteJobByIDResponseObject) { + r, ok := resp.(gen.DeleteJobByID400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "ID") + s.Contains(*r.Error, "uuid") + }, + }, + { + name: "validation error empty id", + request: gen.DeleteJobByIDRequestObject{Id: ""}, + expectMock: false, + validateFunc: func(resp gen.DeleteJobByIDResponseObject) { + r, ok := resp.(gen.DeleteJobByID400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "ID") + s.Contains(*r.Error, "required") + }, + }, + { + name: "success", + request: gen.DeleteJobByIDRequestObject{Id: "550e8400-e29b-41d4-a716-446655440000"}, + expectMock: true, validateFunc: func(resp gen.DeleteJobByIDResponseObject) { _, ok := resp.(gen.DeleteJobByID204Response) s.True(ok) }, }, { - name: "not found", - request: gen.DeleteJobByIDRequestObject{Id: "nonexistent"}, - mockError: fmt.Errorf("job not found: nonexistent"), + name: "not found", + request: gen.DeleteJobByIDRequestObject{Id: "660e8400-e29b-41d4-a716-446655440000"}, + mockError: fmt.Errorf("job not found: 660e8400-e29b-41d4-a716-446655440000"), + expectMock: true, validateFunc: func(resp gen.DeleteJobByIDResponseObject) { _, ok := resp.(gen.DeleteJobByID404JSONResponse) s.True(ok) }, }, { - name: "job client error", - request: gen.DeleteJobByIDRequestObject{Id: "job-1"}, - mockError: assert.AnError, + name: "job client error", + request: gen.DeleteJobByIDRequestObject{Id: "770e8400-e29b-41d4-a716-446655440000"}, + mockError: assert.AnError, + expectMock: true, validateFunc: func(resp gen.DeleteJobByIDResponseObject) { _, ok := resp.(gen.DeleteJobByID500JSONResponse) s.True(ok) @@ -91,9 +119,11 @@ func (s *JobDeletePublicTestSuite) TestDeleteJobByID() { for _, tt := range tests { s.Run(tt.name, func() { - s.mockJobClient.EXPECT(). - DeleteJob(gomock.Any(), tt.request.Id). - Return(tt.mockError) + if tt.expectMock { + s.mockJobClient.EXPECT(). + DeleteJob(gomock.Any(), tt.request.Id). + Return(tt.mockError) + } resp, err := s.handler.DeleteJobByID(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/job/job_get.go b/internal/api/job/job_get.go index ec841f2a..535a58da 100644 --- a/internal/api/job/job_get.go +++ b/internal/api/job/job_get.go @@ -26,6 +26,7 @@ import ( "strings" "github.com/retr0h/osapi/internal/api/job/gen" + "github.com/retr0h/osapi/internal/validation" ) // GetJobByID retrieves details of a specific job by its ID. @@ -33,6 +34,13 @@ func (j *Job) GetJobByID( ctx context.Context, request gen.GetJobByIDRequestObject, ) (gen.GetJobByIDResponseObject, error) { + id := struct { + ID string `validate:"required,uuid"` + }{ID: request.Id} + if errMsg, ok := validation.Struct(id); !ok { + return gen.GetJobByID400JSONResponse{Error: &errMsg}, nil + } + qj, err := j.JobClient.GetJobStatus(ctx, request.Id) if err != nil { errMsg := err.Error() diff --git a/internal/api/job/job_get_public_test.go b/internal/api/job/job_get_public_test.go index d2c6e7b3..9098bf1a 100644 --- a/internal/api/job/job_get_public_test.go +++ b/internal/api/job/job_get_public_test.go @@ -62,20 +62,46 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { request gen.GetJobByIDRequestObject mockJob *jobtypes.QueuedJob mockError error + expectMock bool validateFunc func(resp gen.GetJobByIDResponseObject) }{ + { + name: "validation error invalid uuid", + request: gen.GetJobByIDRequestObject{Id: "not-a-uuid"}, + expectMock: false, + validateFunc: func(resp gen.GetJobByIDResponseObject) { + r, ok := resp.(gen.GetJobByID400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "ID") + s.Contains(*r.Error, "uuid") + }, + }, + { + name: "validation error empty id", + request: gen.GetJobByIDRequestObject{Id: ""}, + expectMock: false, + validateFunc: func(resp gen.GetJobByIDResponseObject) { + r, ok := resp.(gen.GetJobByID400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "ID") + s.Contains(*r.Error, "required") + }, + }, { name: "success with basic fields", - request: gen.GetJobByIDRequestObject{Id: "job-1"}, + request: gen.GetJobByIDRequestObject{Id: "550e8400-e29b-41d4-a716-446655440000"}, mockJob: &jobtypes.QueuedJob{ - ID: "job-1", + ID: "550e8400-e29b-41d4-a716-446655440000", Status: "completed", Created: "2025-06-14T10:00:00Z", }, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { r, ok := resp.(gen.GetJobByID200JSONResponse) s.True(ok) - s.Equal("job-1", *r.Id) + s.Equal("550e8400-e29b-41d4-a716-446655440000", *r.Id) s.Equal("completed", *r.Status) s.Nil(r.Operation) s.Nil(r.Error) @@ -86,9 +112,9 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, { name: "success with all optional fields", - request: gen.GetJobByIDRequestObject{Id: "job-2"}, + request: gen.GetJobByIDRequestObject{Id: "660e8400-e29b-41d4-a716-446655440000"}, mockJob: &jobtypes.QueuedJob{ - ID: "job-2", + ID: "660e8400-e29b-41d4-a716-446655440000", Status: "failed", Created: "2025-06-14T10:00:00Z", Operation: map[string]interface{}{"type": "system.hostname.get"}, @@ -97,10 +123,11 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { UpdatedAt: "2025-06-14T10:05:00Z", Result: json.RawMessage(`{"hostname":"server-01"}`), }, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { r, ok := resp.(gen.GetJobByID200JSONResponse) s.True(ok) - s.Equal("job-2", *r.Id) + s.Equal("660e8400-e29b-41d4-a716-446655440000", *r.Id) s.Equal("failed", *r.Status) s.NotNil(r.Operation) s.Equal("system.hostname.get", (*r.Operation)["type"]) @@ -114,18 +141,20 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, }, { - name: "not found", - request: gen.GetJobByIDRequestObject{Id: "nonexistent"}, - mockError: fmt.Errorf("job not found: nonexistent"), + name: "not found", + request: gen.GetJobByIDRequestObject{Id: "770e8400-e29b-41d4-a716-446655440000"}, + mockError: fmt.Errorf("job not found: 770e8400-e29b-41d4-a716-446655440000"), + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { _, ok := resp.(gen.GetJobByID404JSONResponse) s.True(ok) }, }, { - name: "job client error", - request: gen.GetJobByIDRequestObject{Id: "job-1"}, - mockError: assert.AnError, + name: "job client error", + request: gen.GetJobByIDRequestObject{Id: "880e8400-e29b-41d4-a716-446655440000"}, + mockError: assert.AnError, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { _, ok := resp.(gen.GetJobByID500JSONResponse) s.True(ok) @@ -133,9 +162,9 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, { name: "broadcast job with multiple responses", - request: gen.GetJobByIDRequestObject{Id: "job-3"}, + request: gen.GetJobByIDRequestObject{Id: "990e8400-e29b-41d4-a716-446655440000"}, mockJob: &jobtypes.QueuedJob{ - ID: "job-3", + ID: "990e8400-e29b-41d4-a716-446655440000", Status: "completed", Created: "2025-06-14T10:00:00Z", Responses: map[string]jobtypes.Response{ @@ -161,10 +190,11 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, }, }, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { r, ok := resp.(gen.GetJobByID200JSONResponse) s.True(ok) - s.Equal("job-3", *r.Id) + s.Equal("990e8400-e29b-41d4-a716-446655440000", *r.Id) s.Equal("completed", *r.Status) s.NotNil(r.Responses) s.Len(*r.Responses, 2) @@ -174,9 +204,9 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, { name: "single response omits responses map", - request: gen.GetJobByIDRequestObject{Id: "job-4"}, + request: gen.GetJobByIDRequestObject{Id: "aa0e8400-e29b-41d4-a716-446655440000"}, mockJob: &jobtypes.QueuedJob{ - ID: "job-4", + ID: "aa0e8400-e29b-41d4-a716-446655440000", Status: "completed", Created: "2025-06-14T10:00:00Z", Responses: map[string]jobtypes.Response{ @@ -188,6 +218,7 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, Result: json.RawMessage(`{"hostname":"server1"}`), }, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { r, ok := resp.(gen.GetJobByID200JSONResponse) s.True(ok) @@ -197,9 +228,9 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, { name: "worker states with errors", - request: gen.GetJobByIDRequestObject{Id: "job-5"}, + request: gen.GetJobByIDRequestObject{Id: "bb0e8400-e29b-41d4-a716-446655440000"}, mockJob: &jobtypes.QueuedJob{ - ID: "job-5", + ID: "bb0e8400-e29b-41d4-a716-446655440000", Status: "partial_failure", Created: "2025-06-14T10:00:00Z", Responses: map[string]jobtypes.Response{ @@ -225,6 +256,7 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { }, }, }, + expectMock: true, validateFunc: func(resp gen.GetJobByIDResponseObject) { r, ok := resp.(gen.GetJobByID200JSONResponse) s.True(ok) @@ -240,9 +272,11 @@ func (s *JobGetPublicTestSuite) TestGetJobByID() { for _, tt := range tests { s.Run(tt.name, func() { - s.mockJobClient.EXPECT(). - GetJobStatus(gomock.Any(), tt.request.Id). - Return(tt.mockJob, tt.mockError) + if tt.expectMock { + s.mockJobClient.EXPECT(). + GetJobStatus(gomock.Any(), tt.request.Id). + Return(tt.mockJob, tt.mockError) + } resp, err := s.handler.GetJobByID(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/job/job_list.go b/internal/api/job/job_list.go index 625faf7a..81321ea9 100644 --- a/internal/api/job/job_list.go +++ b/internal/api/job/job_list.go @@ -32,6 +32,20 @@ func (j *Job) GetJob( ctx context.Context, request gen.GetJobRequestObject, ) (gen.GetJobResponseObject, error) { + if request.Params.Status != nil { + validStatuses := map[string]bool{ + "unprocessed": true, + "processing": true, + "completed": true, + "failed": true, + "partial_failure": true, + } + if !validStatuses[*request.Params.Status] { + errMsg := "invalid status filter: must be one of unprocessed, processing, completed, failed, partial_failure" + return gen.GetJob400JSONResponse{Error: &errMsg}, nil + } + } + var statusFilter string if request.Params.Status != nil { statusFilter = *request.Params.Status diff --git a/internal/api/job/job_list_public_test.go b/internal/api/job/job_list_public_test.go index 58bf8f8e..c879962c 100644 --- a/internal/api/job/job_list_public_test.go +++ b/internal/api/job/job_list_public_test.go @@ -57,14 +57,29 @@ func (s *JobListPublicTestSuite) TearDownTest() { func (s *JobListPublicTestSuite) TestGetJob() { completedStatus := "completed" + invalidStatus := "bogus" tests := []struct { name string request gen.GetJobRequestObject mockJobs []*jobtypes.QueuedJob mockError error + expectMock bool validateFunc func(resp gen.GetJobResponseObject) }{ + { + name: "validation error invalid status", + request: gen.GetJobRequestObject{ + Params: gen.GetJobParams{Status: &invalidStatus}, + }, + expectMock: false, + validateFunc: func(resp gen.GetJobResponseObject) { + r, ok := resp.(gen.GetJob400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "invalid status filter") + }, + }, { name: "success with filter", request: gen.GetJobRequestObject{ @@ -76,6 +91,7 @@ func (s *JobListPublicTestSuite) TestGetJob() { Status: "completed", }, }, + expectMock: true, validateFunc: func(resp gen.GetJobResponseObject) { r, ok := resp.(gen.GetJob200JSONResponse) s.True(ok) @@ -90,6 +106,7 @@ func (s *JobListPublicTestSuite) TestGetJob() { {ID: "job-1", Status: "completed"}, {ID: "job-2", Status: "processing"}, }, + expectMock: true, validateFunc: func(resp gen.GetJobResponseObject) { r, ok := resp.(gen.GetJob200JSONResponse) s.True(ok) @@ -111,6 +128,7 @@ func (s *JobListPublicTestSuite) TestGetJob() { Result: json.RawMessage(`{"servers":["8.8.8.8"]}`), }, }, + expectMock: true, validateFunc: func(resp gen.GetJobResponseObject) { r, ok := resp.(gen.GetJob200JSONResponse) s.True(ok) @@ -127,9 +145,10 @@ func (s *JobListPublicTestSuite) TestGetJob() { }, }, { - name: "job client error", - request: gen.GetJobRequestObject{}, - mockError: assert.AnError, + name: "job client error", + request: gen.GetJobRequestObject{}, + mockError: assert.AnError, + expectMock: true, validateFunc: func(resp gen.GetJobResponseObject) { _, ok := resp.(gen.GetJob500JSONResponse) s.True(ok) @@ -139,13 +158,15 @@ func (s *JobListPublicTestSuite) TestGetJob() { for _, tt := range tests { s.Run(tt.name, func() { - var statusFilter string - if tt.request.Params.Status != nil { - statusFilter = *tt.request.Params.Status + if tt.expectMock { + var statusFilter string + if tt.request.Params.Status != nil { + statusFilter = *tt.request.Params.Status + } + s.mockJobClient.EXPECT(). + ListJobs(gomock.Any(), statusFilter). + Return(tt.mockJobs, tt.mockError) } - s.mockJobClient.EXPECT(). - ListJobs(gomock.Any(), statusFilter). - Return(tt.mockJobs, tt.mockError) resp, err := s.handler.GetJob(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/job/job_workers_get.go b/internal/api/job/job_workers_get.go new file mode 100644 index 00000000..d5efd4a4 --- /dev/null +++ b/internal/api/job/job_workers_get.go @@ -0,0 +1,55 @@ +// 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 job + +import ( + "context" + + "github.com/retr0h/osapi/internal/api/job/gen" +) + +// GetJobWorkers discovers all active workers in the fleet. +func (j *Job) GetJobWorkers( + ctx context.Context, + _ gen.GetJobWorkersRequestObject, +) (gen.GetJobWorkersResponseObject, error) { + workers, err := j.JobClient.ListWorkers(ctx) + if err != nil { + errMsg := err.Error() + return gen.GetJobWorkers500JSONResponse{ + Error: &errMsg, + }, nil + } + + workerInfos := make([]gen.WorkerInfo, 0, len(workers)) + for _, w := range workers { + workerInfos = append(workerInfos, gen.WorkerInfo{ + Hostname: w.Hostname, + }) + } + + total := len(workerInfos) + + return gen.GetJobWorkers200JSONResponse{ + Workers: workerInfos, + Total: total, + }, nil +} diff --git a/internal/api/job/job_workers_get_public_test.go b/internal/api/job/job_workers_get_public_test.go new file mode 100644 index 00000000..a9663042 --- /dev/null +++ b/internal/api/job/job_workers_get_public_test.go @@ -0,0 +1,114 @@ +// 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 job_test + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + apijob "github.com/retr0h/osapi/internal/api/job" + "github.com/retr0h/osapi/internal/api/job/gen" + jobtypes "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" +) + +type JobWorkersGetPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apijob.Job + ctx context.Context +} + +func (s *JobWorkersGetPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apijob.New(s.mockJobClient) + s.ctx = context.Background() +} + +func (s *JobWorkersGetPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *JobWorkersGetPublicTestSuite) TestGetJobWorkers() { + tests := []struct { + name string + mockWorkers []jobtypes.WorkerInfo + mockError error + validateFunc func(resp gen.GetJobWorkersResponseObject) + }{ + { + name: "success with workers", + mockWorkers: []jobtypes.WorkerInfo{ + {Hostname: "server1"}, + {Hostname: "server2"}, + }, + validateFunc: func(resp gen.GetJobWorkersResponseObject) { + r, ok := resp.(gen.GetJobWorkers200JSONResponse) + s.True(ok) + s.Equal(2, r.Total) + s.Len(r.Workers, 2) + s.Equal("server1", r.Workers[0].Hostname) + s.Equal("server2", r.Workers[1].Hostname) + }, + }, + { + name: "success with no workers", + mockWorkers: []jobtypes.WorkerInfo{}, + validateFunc: func(resp gen.GetJobWorkersResponseObject) { + r, ok := resp.(gen.GetJobWorkers200JSONResponse) + s.True(ok) + s.Equal(0, r.Total) + s.Empty(r.Workers) + }, + }, + { + name: "job client error", + mockError: assert.AnError, + validateFunc: func(resp gen.GetJobWorkersResponseObject) { + _, ok := resp.(gen.GetJobWorkers500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + s.mockJobClient.EXPECT(). + ListWorkers(gomock.Any()). + Return(tt.mockWorkers, tt.mockError) + + resp, err := s.handler.GetJobWorkers(s.ctx, gen.GetJobWorkersRequestObject{}) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func TestJobWorkersGetPublicTestSuite(t *testing.T) { + suite.Run(t, new(JobWorkersGetPublicTestSuite)) +} diff --git a/internal/api/network/gen/api.yaml b/internal/api/network/gen/api.yaml index 7c1f3ddb..7c4322ce 100644 --- a/internal/api/network/gen/api.yaml +++ b/internal/api/network/gen/api.yaml @@ -41,6 +41,15 @@ paths: security: - BearerAuth: - write + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). requestBody: description: The server to ping. required: true @@ -110,6 +119,14 @@ paths: description: > The name of the network interface to retrieve DNS configuration for. Must only contain letters and numbers. + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': @@ -153,6 +170,15 @@ paths: security: - BearerAuth: - write + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). requestBody: required: true content: diff --git a/internal/api/network/gen/network.gen.go b/internal/api/network/gen/network.gen.go index 1ca845f7..a15c55e1 100644 --- a/internal/api/network/gen/network.gen.go +++ b/internal/api/network/gen/network.gen.go @@ -64,12 +64,30 @@ type PingResponse struct { PacketsSent *int `json:"packets_sent,omitempty"` } +// PutNetworkDNSParams defines parameters for PutNetworkDNS. +type PutNetworkDNSParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + +// GetNetworkDNSByInterfaceParams defines parameters for GetNetworkDNSByInterface. +type GetNetworkDNSByInterfaceParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + // PostNetworkPingJSONBody defines parameters for PostNetworkPing. type PostNetworkPingJSONBody struct { // Address The IP address of the server to ping. Supports both IPv4 and IPv6. Address string `json:"address" validate:"required,ip"` } +// PostNetworkPingParams defines parameters for PostNetworkPing. +type PostNetworkPingParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + // PutNetworkDNSJSONRequestBody defines body for PutNetworkDNS for application/json ContentType. type PutNetworkDNSJSONRequestBody = DNSConfigUpdateRequest @@ -80,13 +98,13 @@ type PostNetworkPingJSONRequestBody PostNetworkPingJSONBody type ServerInterface interface { // Update DNS servers // (PUT /network/dns) - PutNetworkDNS(ctx echo.Context) error + PutNetworkDNS(ctx echo.Context, params PutNetworkDNSParams) error // List DNS servers // (GET /network/dns/{interfaceName}) - GetNetworkDNSByInterface(ctx echo.Context, interfaceName string) error + GetNetworkDNSByInterface(ctx echo.Context, interfaceName string, params GetNetworkDNSByInterfaceParams) error // Ping a remote server // (POST /network/ping) - PostNetworkPing(ctx echo.Context) error + PostNetworkPing(ctx echo.Context, params PostNetworkPingParams) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -100,8 +118,17 @@ func (w *ServerInterfaceWrapper) PutNetworkDNS(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"write"}) + // Parameter object where we will unmarshal all parameters from the context + var params PutNetworkDNSParams + // ------------- Optional query parameter "target_hostname" ------------- + + err = runtime.BindQueryParameter("form", true, false, "target_hostname", ctx.QueryParams(), ¶ms.TargetHostname) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter target_hostname: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PutNetworkDNS(ctx) + err = w.Handler.PutNetworkDNS(ctx, params) return err } @@ -118,8 +145,17 @@ func (w *ServerInterfaceWrapper) GetNetworkDNSByInterface(ctx echo.Context) erro ctx.Set(BearerAuthScopes, []string{"read"}) + // Parameter object where we will unmarshal all parameters from the context + var params GetNetworkDNSByInterfaceParams + // ------------- Optional query parameter "target_hostname" ------------- + + err = runtime.BindQueryParameter("form", true, false, "target_hostname", ctx.QueryParams(), ¶ms.TargetHostname) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter target_hostname: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetNetworkDNSByInterface(ctx, interfaceName) + err = w.Handler.GetNetworkDNSByInterface(ctx, interfaceName, params) return err } @@ -129,8 +165,17 @@ func (w *ServerInterfaceWrapper) PostNetworkPing(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"write"}) + // Parameter object where we will unmarshal all parameters from the context + var params PostNetworkPingParams + // ------------- Optional query parameter "target_hostname" ------------- + + err = runtime.BindQueryParameter("form", true, false, "target_hostname", ctx.QueryParams(), ¶ms.TargetHostname) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter target_hostname: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments - err = w.Handler.PostNetworkPing(ctx) + err = w.Handler.PostNetworkPing(ctx, params) return err } @@ -169,7 +214,8 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } type PutNetworkDNSRequestObject struct { - Body *PutNetworkDNSJSONRequestBody + Params PutNetworkDNSParams + Body *PutNetworkDNSJSONRequestBody } type PutNetworkDNSResponseObject interface { @@ -222,6 +268,7 @@ func (response PutNetworkDNS500JSONResponse) VisitPutNetworkDNSResponse(w http.R type GetNetworkDNSByInterfaceRequestObject struct { InterfaceName string `json:"interfaceName"` + Params GetNetworkDNSByInterfaceParams } type GetNetworkDNSByInterfaceResponseObject interface { @@ -274,7 +321,8 @@ func (response GetNetworkDNSByInterface500JSONResponse) VisitGetNetworkDNSByInte } type PostNetworkPingRequestObject struct { - Body *PostNetworkPingJSONRequestBody + Params PostNetworkPingParams + Body *PostNetworkPingJSONRequestBody } type PostNetworkPingResponseObject interface { @@ -352,9 +400,11 @@ type strictHandler struct { } // PutNetworkDNS operation middleware -func (sh *strictHandler) PutNetworkDNS(ctx echo.Context) error { +func (sh *strictHandler) PutNetworkDNS(ctx echo.Context, params PutNetworkDNSParams) error { var request PutNetworkDNSRequestObject + request.Params = params + var body PutNetworkDNSJSONRequestBody if err := ctx.Bind(&body); err != nil { return err @@ -381,10 +431,11 @@ func (sh *strictHandler) PutNetworkDNS(ctx echo.Context) error { } // GetNetworkDNSByInterface operation middleware -func (sh *strictHandler) GetNetworkDNSByInterface(ctx echo.Context, interfaceName string) error { +func (sh *strictHandler) GetNetworkDNSByInterface(ctx echo.Context, interfaceName string, params GetNetworkDNSByInterfaceParams) error { var request GetNetworkDNSByInterfaceRequestObject request.InterfaceName = interfaceName + request.Params = params handler := func(ctx echo.Context, request interface{}) (interface{}, error) { return sh.ssi.GetNetworkDNSByInterface(ctx.Request().Context(), request.(GetNetworkDNSByInterfaceRequestObject)) @@ -406,9 +457,11 @@ func (sh *strictHandler) GetNetworkDNSByInterface(ctx echo.Context, interfaceNam } // PostNetworkPing operation middleware -func (sh *strictHandler) PostNetworkPing(ctx echo.Context) error { +func (sh *strictHandler) PostNetworkPing(ctx echo.Context, params PostNetworkPingParams) error { var request PostNetworkPingRequestObject + request.Params = params + var body PostNetworkPingJSONRequestBody if err := ctx.Bind(&body); err != nil { return err diff --git a/internal/api/network/network_dns_get_by_interface.go b/internal/api/network/network_dns_get_by_interface.go index 157b97f3..d1379823 100644 --- a/internal/api/network/network_dns_get_by_interface.go +++ b/internal/api/network/network_dns_get_by_interface.go @@ -22,17 +22,59 @@ package network import ( "context" + "encoding/json" + "net/http" "github.com/retr0h/osapi/internal/api/network/gen" "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/validation" ) +// dnsMultiResponse wraps multiple DNS results for _all broadcast. +type dnsMultiResponse struct { + Results []gen.DNSConfigResponse `json:"results"` +} + +func (r dnsMultiResponse) VisitGetNetworkDNSByInterfaceResponse( + w http.ResponseWriter, +) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + return json.NewEncoder(w).Encode(r) +} + // GetNetworkDNSByInterface get the network dns get API endpoint. func (n Network) GetNetworkDNSByInterface( ctx context.Context, request gen.GetNetworkDNSByInterfaceRequestObject, ) (gen.GetNetworkDNSByInterfaceResponseObject, error) { - dnsConfig, err := n.JobClient.QueryNetworkDNS(ctx, job.AnyHost, request.InterfaceName) + iface := struct { + InterfaceName string `validate:"required,alphanum"` + }{InterfaceName: request.InterfaceName} + if errMsg, ok := validation.Struct(iface); !ok { + return gen.GetNetworkDNSByInterface400JSONResponse{Error: &errMsg}, nil + } + + if request.Params.TargetHostname != nil { + th := struct { + TargetHostname string `validate:"min=1"` + }{TargetHostname: *request.Params.TargetHostname} + if errMsg, ok := validation.Struct(th); !ok { + return gen.GetNetworkDNSByInterface400JSONResponse{Error: &errMsg}, nil + } + } + + hostname := job.AnyHost + if request.Params.TargetHostname != nil { + hostname = *request.Params.TargetHostname + } + + if hostname == job.BroadcastHost { + return n.getNetworkDNSAll(ctx, request.InterfaceName) + } + + dnsConfig, err := n.JobClient.QueryNetworkDNS(ctx, hostname, request.InterfaceName) if err != nil { errMsg := err.Error() return gen.GetNetworkDNSByInterface500JSONResponse{ @@ -48,3 +90,29 @@ func (n Network) GetNetworkDNSByInterface( Servers: &servers, }, nil } + +// getNetworkDNSAll handles _all broadcast for DNS config. +func (n Network) getNetworkDNSAll( + ctx context.Context, + iface string, +) (gen.GetNetworkDNSByInterfaceResponseObject, error) { + results, err := n.JobClient.QueryNetworkDNSAll(ctx, iface) + if err != nil { + errMsg := err.Error() + return gen.GetNetworkDNSByInterface500JSONResponse{ + Error: &errMsg, + }, nil + } + + var responses []gen.DNSConfigResponse + for _, cfg := range results { + servers := cfg.DNSServers + searchDomains := cfg.SearchDomains + responses = append(responses, gen.DNSConfigResponse{ + Servers: &servers, + SearchDomains: &searchDomains, + }) + } + + return dnsMultiResponse{Results: responses}, nil +} diff --git a/internal/api/network/network_dns_get_by_interface_integration_test.go b/internal/api/network/network_dns_get_by_interface_integration_test.go new file mode 100644 index 00000000..6ed68efd --- /dev/null +++ b/internal/api/network/network_dns_get_by_interface_integration_test.go @@ -0,0 +1,120 @@ +// 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 network_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/internal/api" + apinetwork "github.com/retr0h/osapi/internal/api/network" + networkGen "github.com/retr0h/osapi/internal/api/network/gen" + "github.com/retr0h/osapi/internal/config" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" + "github.com/retr0h/osapi/internal/provider/network/dns" +) + +type NetworkDNSGetByInterfaceIntegrationTestSuite struct { + suite.Suite + ctrl *gomock.Controller + + appConfig config.Config + logger *slog.Logger +} + +func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) SetupTest() { + suite.ctrl = gomock.NewController(suite.T()) + + suite.appConfig = config.Config{} + suite.logger = slog.New(slog.NewTextHandler(os.Stdout, nil)) +} + +func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TearDownTest() { + suite.ctrl.Finish() +} + +func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByInterface() { + tests := []struct { + name string + path string + setupJobMock func() *jobmocks.MockJobClient + wantCode int + wantContains []string + }{ + { + name: "when valid request", + path: "/network/dns/eth0", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + QueryNetworkDNS(gomock.Any(), gomock.Any(), "eth0"). + Return(&dns.Config{ + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"servers"`, `"8.8.8.8"`, `"search_domains"`, `"example.com"`}, + }, + { + name: "when non-alphanum interface name", + path: "/network/dns/eth-0!", + setupJobMock: func() *jobmocks.MockJobClient { + return jobmocks.NewMockJobClient(suite.ctrl) + }, + wantCode: http.StatusBadRequest, + wantContains: []string{`"error"`, "InterfaceName", "alphanum"}, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + jobMock := tc.setupJobMock() + + networkHandler := apinetwork.New(jobMock) + strictHandler := networkGen.NewStrictHandler(networkHandler, nil) + + a := api.New(suite.appConfig, suite.logger) + networkGen.RegisterHandlers(a.Echo, strictHandler) + + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rec := httptest.NewRecorder() + + a.Echo.ServeHTTP(rec, req) + + suite.Equal(tc.wantCode, rec.Code) + for _, s := range tc.wantContains { + suite.Contains(rec.Body.String(), s) + } + }) + } +} + +func TestNetworkDNSGetByInterfaceIntegrationTestSuite(t *testing.T) { + suite.Run(t, new(NetworkDNSGetByInterfaceIntegrationTestSuite)) +} diff --git a/internal/api/network/network_dns_get_by_interface_public_test.go b/internal/api/network/network_dns_get_by_interface_public_test.go index 25f93b09..577701e7 100644 --- a/internal/api/network/network_dns_get_by_interface_public_test.go +++ b/internal/api/network/network_dns_get_by_interface_public_test.go @@ -61,6 +61,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() request gen.GetNetworkDNSByInterfaceRequestObject mockConfig *dns.Config mockError error + expectMock bool validateFunc func(resp gen.GetNetworkDNSByInterfaceResponseObject) }{ { @@ -70,6 +71,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() DNSServers: []string{"192.168.1.1", "8.8.8.8"}, SearchDomains: []string{"example.com", "local.lan"}, }, + expectMock: true, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface200JSONResponse) s.True(ok) @@ -78,9 +80,49 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, }, { - name: "job client error", - request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth0"}, - mockError: assert.AnError, + name: "validation error non-alphanum interface name", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth-0!"}, + expectMock: false, + validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { + r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "InterfaceName") + s.Contains(*r.Error, "alphanum") + }, + }, + { + name: "validation error empty interface name", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: ""}, + expectMock: false, + validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { + r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "InterfaceName") + s.Contains(*r.Error, "required") + }, + }, + { + name: "validation error empty target_hostname", + request: gen.GetNetworkDNSByInterfaceRequestObject{ + InterfaceName: "eth0", + Params: gen.GetNetworkDNSByInterfaceParams{TargetHostname: strPtr("")}, + }, + expectMock: false, + validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { + r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "TargetHostname") + s.Contains(*r.Error, "min") + }, + }, + { + name: "job client error", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth0"}, + mockError: assert.AnError, + expectMock: true, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { _, ok := resp.(gen.GetNetworkDNSByInterface500JSONResponse) s.True(ok) @@ -90,9 +132,11 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() for _, tt := range tests { s.Run(tt.name, func() { - s.mockJobClient.EXPECT(). - QueryNetworkDNS(gomock.Any(), gomock.Any(), tt.request.InterfaceName). - Return(tt.mockConfig, tt.mockError) + if tt.expectMock { + s.mockJobClient.EXPECT(). + QueryNetworkDNS(gomock.Any(), gomock.Any(), tt.request.InterfaceName). + Return(tt.mockConfig, tt.mockError) + } resp, err := s.handler.GetNetworkDNSByInterface(s.ctx, tt.request) s.NoError(err) @@ -101,6 +145,12 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() } } +func strPtr( + s string, +) *string { + return &s +} + func TestNetworkDNSGetByInterfacePublicTestSuite(t *testing.T) { suite.Run(t, new(NetworkDNSGetByInterfacePublicTestSuite)) } diff --git a/internal/api/network/network_dns_put_by_interface.go b/internal/api/network/network_dns_put_by_interface.go index 46f36b90..781736bd 100644 --- a/internal/api/network/network_dns_put_by_interface.go +++ b/internal/api/network/network_dns_put_by_interface.go @@ -22,11 +22,35 @@ package network import ( "context" + "encoding/json" + "fmt" + "net/http" "github.com/retr0h/osapi/internal/api/network/gen" + "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/validation" ) +// dnsPutMultiResponse wraps multiple DNS modify results for _all broadcast. +type dnsPutMultiResponse struct { + Results []dnsPutResult `json:"results"` +} + +type dnsPutResult struct { + Hostname string `json:"hostname"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +func (r dnsPutMultiResponse) VisitPutNetworkDNSResponse( + w http.ResponseWriter, +) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + + return json.NewEncoder(w).Encode(r) +} + // PutNetworkDNS put the network dns API endpoint. func (n Network) PutNetworkDNS( ctx context.Context, @@ -38,6 +62,15 @@ func (n Network) PutNetworkDNS( }, nil } + if request.Params.TargetHostname != nil { + th := struct { + TargetHostname string `validate:"min=1"` + }{TargetHostname: *request.Params.TargetHostname} + if errMsg, ok := validation.Struct(th); !ok { + return gen.PutNetworkDNS400JSONResponse{Error: &errMsg}, nil + } + } + var servers []string if request.Body.Servers != nil { servers = *request.Body.Servers @@ -50,7 +83,16 @@ func (n Network) PutNetworkDNS( interfaceName := request.Body.InterfaceName - err := n.JobClient.ModifyNetworkDNSAny(ctx, servers, searchDomains, interfaceName) + hostname := job.AnyHost + if request.Params.TargetHostname != nil { + hostname = *request.Params.TargetHostname + } + + if hostname == job.BroadcastHost { + return n.putNetworkDNSAll(ctx, servers, searchDomains, interfaceName) + } + + err := n.JobClient.ModifyNetworkDNS(ctx, hostname, servers, searchDomains, interfaceName) if err != nil { errMsg := err.Error() return gen.PutNetworkDNS500JSONResponse{ @@ -60,3 +102,34 @@ func (n Network) PutNetworkDNS( return gen.PutNetworkDNS202Response{}, nil } + +// putNetworkDNSAll handles _all broadcast for DNS modification. +func (n Network) putNetworkDNSAll( + ctx context.Context, + servers []string, + searchDomains []string, + interfaceName string, +) (gen.PutNetworkDNSResponseObject, error) { + results, err := n.JobClient.ModifyNetworkDNSAll(ctx, servers, searchDomains, interfaceName) + if err != nil { + errMsg := err.Error() + return gen.PutNetworkDNS500JSONResponse{ + Error: &errMsg, + }, nil + } + + var responses []dnsPutResult + for host, hostErr := range results { + r := dnsPutResult{ + Hostname: host, + Status: "ok", + } + if hostErr != nil { + r.Status = "failed" + r.Error = fmt.Sprintf("%v", hostErr) + } + responses = append(responses, r) + } + + return dnsPutMultiResponse{Results: responses}, nil +} diff --git a/internal/api/network/network_dns_put_by_interface_integration_test.go b/internal/api/network/network_dns_put_by_interface_integration_test.go index f24707bf..3a38e90a 100644 --- a/internal/api/network/network_dns_put_by_interface_integration_test.go +++ b/internal/api/network/network_dns_put_by_interface_integration_test.go @@ -71,7 +71,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - ModifyNetworkDNSAny(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil) return mock }, diff --git a/internal/api/network/network_dns_put_by_interface_public_test.go b/internal/api/network/network_dns_put_by_interface_public_test.go index 5d99e9af..c0559bc9 100644 --- a/internal/api/network/network_dns_put_by_interface_public_test.go +++ b/internal/api/network/network_dns_put_by_interface_public_test.go @@ -96,6 +96,25 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { s.Contains(*r.Error, "required") }, }, + { + name: "validation error empty target_hostname", + request: gen.PutNetworkDNSRequestObject{ + Body: &gen.PutNetworkDNSJSONRequestBody{ + Servers: &servers, + SearchDomains: &searchDomains, + InterfaceName: interfaceName, + }, + Params: gen.PutNetworkDNSParams{TargetHostname: strPtr("")}, + }, + expectMock: false, + validateFunc: func(resp gen.PutNetworkDNSResponseObject) { + r, ok := resp.(gen.PutNetworkDNS400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "TargetHostname") + s.Contains(*r.Error, "min") + }, + }, { name: "job client error", request: gen.PutNetworkDNSRequestObject{ @@ -118,7 +137,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { s.Run(tt.name, func() { if tt.expectMock { s.mockJobClient.EXPECT(). - ModifyNetworkDNSAny(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(tt.mockError) } diff --git a/internal/api/network/network_ping_post.go b/internal/api/network/network_ping_post.go index 8e6aa77c..06cda058 100644 --- a/internal/api/network/network_ping_post.go +++ b/internal/api/network/network_ping_post.go @@ -22,13 +22,31 @@ package network import ( "context" + "encoding/json" "fmt" + "net/http" "time" "github.com/retr0h/osapi/internal/api/network/gen" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/provider/network/ping" "github.com/retr0h/osapi/internal/validation" ) +// pingMultiResponse wraps multiple ping results for _all broadcast. +type pingMultiResponse struct { + Results []gen.PingResponse `json:"results"` +} + +func (r pingMultiResponse) VisitPostNetworkPingResponse( + w http.ResponseWriter, +) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + return json.NewEncoder(w).Encode(r) +} + // PostNetworkPing post the network ping API endpoint. func (n Network) PostNetworkPing( ctx context.Context, @@ -40,7 +58,25 @@ func (n Network) PostNetworkPing( }, nil } - pingResult, err := n.JobClient.QueryNetworkPingAny(ctx, request.Body.Address) + if request.Params.TargetHostname != nil { + th := struct { + TargetHostname string `validate:"min=1"` + }{TargetHostname: *request.Params.TargetHostname} + if errMsg, ok := validation.Struct(th); !ok { + return gen.PostNetworkPing400JSONResponse{Error: &errMsg}, nil + } + } + + hostname := job.AnyHost + if request.Params.TargetHostname != nil { + hostname = *request.Params.TargetHostname + } + + if hostname == job.BroadcastHost { + return n.postNetworkPingAll(ctx, request.Body.Address) + } + + pingResult, err := n.JobClient.QueryNetworkPing(ctx, hostname, request.Body.Address) if err != nil { errMsg := err.Error() return gen.PostNetworkPing500JSONResponse{ @@ -48,14 +84,42 @@ func (n Network) PostNetworkPing( }, nil } + return buildPingResponse(pingResult), nil +} + +// postNetworkPingAll handles _all broadcast for ping. +func (n Network) postNetworkPingAll( + ctx context.Context, + address string, +) (gen.PostNetworkPingResponseObject, error) { + results, err := n.JobClient.QueryNetworkPingAll(ctx, address) + if err != nil { + errMsg := err.Error() + return gen.PostNetworkPing500JSONResponse{ + Error: &errMsg, + }, nil + } + + var responses []gen.PingResponse + for _, r := range results { + responses = append(responses, gen.PingResponse(buildPingResponse(r))) + } + + return pingMultiResponse{Results: responses}, nil +} + +// buildPingResponse converts a ping.Result to the API response. +func buildPingResponse( + r *ping.Result, +) gen.PostNetworkPing200JSONResponse { return gen.PostNetworkPing200JSONResponse{ - AvgRtt: durationToString(&pingResult.AvgRTT), - MaxRtt: durationToString(&pingResult.MaxRTT), - MinRtt: durationToString(&pingResult.MinRTT), - PacketLoss: &pingResult.PacketLoss, - PacketsReceived: &pingResult.PacketsReceived, - PacketsSent: &pingResult.PacketsSent, - }, nil + AvgRtt: durationToString(&r.AvgRTT), + MaxRtt: durationToString(&r.MaxRTT), + MinRtt: durationToString(&r.MinRTT), + PacketLoss: &r.PacketLoss, + PacketsReceived: &r.PacketsReceived, + PacketsSent: &r.PacketsSent, + } } // durationToString convert *time.Duration to *string. diff --git a/internal/api/network/network_ping_post_integration_test.go b/internal/api/network/network_ping_post_integration_test.go index cc930da8..9397ab80 100644 --- a/internal/api/network/network_ping_post_integration_test.go +++ b/internal/api/network/network_ping_post_integration_test.go @@ -73,7 +73,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QueryNetworkPingAny(gomock.Any(), "1.1.1.1"). + QueryNetworkPing(gomock.Any(), gomock.Any(), "1.1.1.1"). Return(&ping.Result{ PacketsSent: 3, PacketsReceived: 3, diff --git a/internal/api/network/network_ping_post_public_test.go b/internal/api/network/network_ping_post_public_test.go index fff1d191..d6b1b9f2 100644 --- a/internal/api/network/network_ping_post_public_test.go +++ b/internal/api/network/network_ping_post_public_test.go @@ -104,6 +104,23 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { s.Contains(*r.Error, "ip") }, }, + { + name: "validation error empty target_hostname", + request: gen.PostNetworkPingRequestObject{ + Body: &gen.PostNetworkPingJSONRequestBody{ + Address: "1.1.1.1", + }, + Params: gen.PostNetworkPingParams{TargetHostname: strPtr("")}, + }, + expectMock: false, + validateFunc: func(resp gen.PostNetworkPingResponseObject) { + r, ok := resp.(gen.PostNetworkPing400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "TargetHostname") + s.Contains(*r.Error, "min") + }, + }, { name: "job client error", request: gen.PostNetworkPingRequestObject{ @@ -124,7 +141,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { s.Run(tt.name, func() { if tt.expectMock { s.mockJobClient.EXPECT(). - QueryNetworkPingAny(gomock.Any(), tt.request.Body.Address). + QueryNetworkPing(gomock.Any(), gomock.Any(), tt.request.Body.Address). Return(tt.mockResult, tt.mockError) } diff --git a/internal/api/system/gen/api.yaml b/internal/api/system/gen/api.yaml index c18b3b4f..39c035e2 100644 --- a/internal/api/system/gen/api.yaml +++ b/internal/api/system/gen/api.yaml @@ -40,6 +40,15 @@ paths: security: - BearerAuth: - read + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': description: A JSON object containing the system's status information. @@ -47,6 +56,12 @@ paths: application/json: schema: $ref: '#/components/schemas/SystemStatusResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' '401': description: Unauthorized - API key required content: @@ -74,6 +89,15 @@ paths: security: - BearerAuth: - read + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': description: A JSON object containing the system's hostname. @@ -81,6 +105,12 @@ paths: application/json: schema: $ref: '#/components/schemas/HostnameResponse' + '400': + description: Invalid request parameters. + content: + application/json: + schema: + $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse' '401': description: Unauthorized - API key required content: diff --git a/internal/api/system/gen/system.gen.go b/internal/api/system/gen/system.gen.go index 09471b96..0243fb75 100644 --- a/internal/api/system/gen/system.gen.go +++ b/internal/api/system/gen/system.gen.go @@ -10,6 +10,7 @@ import ( "net/http" "github.com/labstack/echo/v4" + "github.com/oapi-codegen/runtime" strictecho "github.com/oapi-codegen/runtime/strictmiddleware/echo" externalRef0 "github.com/retr0h/osapi/internal/api/common/gen" ) @@ -99,14 +100,26 @@ type SystemStatusResponse struct { Uptime string `json:"uptime"` } +// GetSystemHostnameParams defines parameters for GetSystemHostname. +type GetSystemHostnameParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + +// GetSystemStatusParams defines parameters for GetSystemStatus. +type GetSystemStatusParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Retrieve system hostname // (GET /system/hostname) - GetSystemHostname(ctx echo.Context) error + GetSystemHostname(ctx echo.Context, params GetSystemHostnameParams) error // Retrieve system status // (GET /system/status) - GetSystemStatus(ctx echo.Context) error + GetSystemStatus(ctx echo.Context, params GetSystemStatusParams) error } // ServerInterfaceWrapper converts echo contexts to parameters. @@ -120,8 +133,17 @@ func (w *ServerInterfaceWrapper) GetSystemHostname(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"read"}) + // Parameter object where we will unmarshal all parameters from the context + var params GetSystemHostnameParams + // ------------- Optional query parameter "target_hostname" ------------- + + err = runtime.BindQueryParameter("form", true, false, "target_hostname", ctx.QueryParams(), ¶ms.TargetHostname) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter target_hostname: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetSystemHostname(ctx) + err = w.Handler.GetSystemHostname(ctx, params) return err } @@ -131,8 +153,17 @@ func (w *ServerInterfaceWrapper) GetSystemStatus(ctx echo.Context) error { ctx.Set(BearerAuthScopes, []string{"read"}) + // Parameter object where we will unmarshal all parameters from the context + var params GetSystemStatusParams + // ------------- Optional query parameter "target_hostname" ------------- + + err = runtime.BindQueryParameter("form", true, false, "target_hostname", ctx.QueryParams(), ¶ms.TargetHostname) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter target_hostname: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments - err = w.Handler.GetSystemStatus(ctx) + err = w.Handler.GetSystemStatus(ctx, params) return err } @@ -170,6 +201,7 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL } type GetSystemHostnameRequestObject struct { + Params GetSystemHostnameParams } type GetSystemHostnameResponseObject interface { @@ -185,6 +217,15 @@ func (response GetSystemHostname200JSONResponse) VisitGetSystemHostnameResponse( return json.NewEncoder(w).Encode(response) } +type GetSystemHostname400JSONResponse externalRef0.ErrorResponse + +func (response GetSystemHostname400JSONResponse) VisitGetSystemHostnameResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type GetSystemHostname401JSONResponse externalRef0.ErrorResponse func (response GetSystemHostname401JSONResponse) VisitGetSystemHostnameResponse(w http.ResponseWriter) error { @@ -213,6 +254,7 @@ func (response GetSystemHostname500JSONResponse) VisitGetSystemHostnameResponse( } type GetSystemStatusRequestObject struct { + Params GetSystemStatusParams } type GetSystemStatusResponseObject interface { @@ -228,6 +270,15 @@ func (response GetSystemStatus200JSONResponse) VisitGetSystemStatusResponse(w ht return json.NewEncoder(w).Encode(response) } +type GetSystemStatus400JSONResponse externalRef0.ErrorResponse + +func (response GetSystemStatus400JSONResponse) VisitGetSystemStatusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + type GetSystemStatus401JSONResponse externalRef0.ErrorResponse func (response GetSystemStatus401JSONResponse) VisitGetSystemStatusResponse(w http.ResponseWriter) error { @@ -278,9 +329,11 @@ type strictHandler struct { } // GetSystemHostname operation middleware -func (sh *strictHandler) GetSystemHostname(ctx echo.Context) error { +func (sh *strictHandler) GetSystemHostname(ctx echo.Context, params GetSystemHostnameParams) error { var request GetSystemHostnameRequestObject + request.Params = params + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { return sh.ssi.GetSystemHostname(ctx.Request().Context(), request.(GetSystemHostnameRequestObject)) } @@ -301,9 +354,11 @@ func (sh *strictHandler) GetSystemHostname(ctx echo.Context) error { } // GetSystemStatus operation middleware -func (sh *strictHandler) GetSystemStatus(ctx echo.Context) error { +func (sh *strictHandler) GetSystemStatus(ctx echo.Context, params GetSystemStatusParams) error { var request GetSystemStatusRequestObject + request.Params = params + handler := func(ctx echo.Context, request interface{}) (interface{}, error) { return sh.ssi.GetSystemStatus(ctx.Request().Context(), request.(GetSystemStatusRequestObject)) } diff --git a/internal/api/system/system_hostname_get.go b/internal/api/system/system_hostname_get.go index e4ac583b..b702605f 100644 --- a/internal/api/system/system_hostname_get.go +++ b/internal/api/system/system_hostname_get.go @@ -22,17 +22,52 @@ package system import ( "context" + "encoding/json" + "net/http" "github.com/retr0h/osapi/internal/api/system/gen" "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/validation" ) +// hostnameMultiResponse wraps multiple hostname results for _all broadcast. +type hostnameMultiResponse struct { + Results []gen.HostnameResponse `json:"results"` +} + +func (r hostnameMultiResponse) VisitGetSystemHostnameResponse( + w http.ResponseWriter, +) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + return json.NewEncoder(w).Encode(r) +} + // GetSystemHostname get the system hostname API endpoint. func (s *System) GetSystemHostname( ctx context.Context, - _ gen.GetSystemHostnameRequestObject, + request gen.GetSystemHostnameRequestObject, ) (gen.GetSystemHostnameResponseObject, error) { - hostname, err := s.JobClient.QuerySystemHostname(ctx, job.AnyHost) + if request.Params.TargetHostname != nil { + th := struct { + TargetHostname string `validate:"min=1"` + }{TargetHostname: *request.Params.TargetHostname} + if errMsg, ok := validation.Struct(th); !ok { + return gen.GetSystemHostname400JSONResponse{Error: &errMsg}, nil + } + } + + hostname := job.AnyHost + if request.Params.TargetHostname != nil { + hostname = *request.Params.TargetHostname + } + + if hostname == job.BroadcastHost { + return s.getSystemHostnameAll(ctx) + } + + result, err := s.JobClient.QuerySystemHostname(ctx, hostname) if err != nil { errMsg := err.Error() return gen.GetSystemHostname500JSONResponse{ @@ -41,6 +76,26 @@ func (s *System) GetSystemHostname( } return gen.GetSystemHostname200JSONResponse{ - Hostname: hostname, + Hostname: result, }, nil } + +// getSystemHostnameAll handles _all broadcast for system hostname. +func (s *System) getSystemHostnameAll( + ctx context.Context, +) (gen.GetSystemHostnameResponseObject, error) { + results, err := s.JobClient.QuerySystemHostnameAll(ctx) + if err != nil { + errMsg := err.Error() + return gen.GetSystemHostname500JSONResponse{ + Error: &errMsg, + }, nil + } + + var responses []gen.HostnameResponse + for _, h := range results { + responses = append(responses, gen.HostnameResponse{Hostname: h}) + } + + return hostnameMultiResponse{Results: responses}, nil +} diff --git a/internal/api/system/system_hostname_get_public_test.go b/internal/api/system/system_hostname_get_public_test.go new file mode 100644 index 00000000..6ef2ea3f --- /dev/null +++ b/internal/api/system/system_hostname_get_public_test.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package system_test + +import ( + "context" + "testing" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + apisystem "github.com/retr0h/osapi/internal/api/system" + "github.com/retr0h/osapi/internal/api/system/gen" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" +) + +type SystemHostnameGetPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apisystem.System + ctx context.Context +} + +func (s *SystemHostnameGetPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apisystem.New(s.mockJobClient) + s.ctx = context.Background() +} + +func (s *SystemHostnameGetPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { + tests := []struct { + name string + request gen.GetSystemHostnameRequestObject + mockResult string + mockError error + expectMock bool + validateFunc func(resp gen.GetSystemHostnameResponseObject) + }{ + { + name: "success", + request: gen.GetSystemHostnameRequestObject{}, + mockResult: "my-hostname", + expectMock: true, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + r, ok := resp.(gen.GetSystemHostname200JSONResponse) + s.True(ok) + s.Equal("my-hostname", r.Hostname) + }, + }, + { + name: "validation error empty target_hostname", + request: gen.GetSystemHostnameRequestObject{ + Params: gen.GetSystemHostnameParams{TargetHostname: strPtr("")}, + }, + expectMock: false, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + r, ok := resp.(gen.GetSystemHostname400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "TargetHostname") + s.Contains(*r.Error, "min") + }, + }, + { + name: "job client error", + request: gen.GetSystemHostnameRequestObject{}, + mockError: assert.AnError, + expectMock: true, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + _, ok := resp.(gen.GetSystemHostname500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + if tt.expectMock { + s.mockJobClient.EXPECT(). + QuerySystemHostname(gomock.Any(), gomock.Any()). + Return(tt.mockResult, tt.mockError) + } + + resp, err := s.handler.GetSystemHostname(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func strPtr( + s string, +) *string { + return &s +} + +func TestSystemHostnameGetPublicTestSuite(t *testing.T) { + suite.Run(t, new(SystemHostnameGetPublicTestSuite)) +} diff --git a/internal/api/system/system_status_get.go b/internal/api/system/system_status_get.go index 73a1394f..60e16035 100644 --- a/internal/api/system/system_status_get.go +++ b/internal/api/system/system_status_get.go @@ -22,18 +22,71 @@ package system import ( "context" + "encoding/json" "fmt" + "net/http" "time" "github.com/retr0h/osapi/internal/api/system/gen" + "github.com/retr0h/osapi/internal/job" + "github.com/retr0h/osapi/internal/validation" ) +// systemStatusMultiResponse wraps multiple status results for _all broadcast. +type systemStatusMultiResponse struct { + Results []gen.SystemStatusResponse `json:"results"` +} + +func (r systemStatusMultiResponse) VisitGetSystemStatusResponse( + w http.ResponseWriter, +) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + return json.NewEncoder(w).Encode(r) +} + // GetSystemStatus get the system status API endpoint. func (s *System) GetSystemStatus( ctx context.Context, - _ gen.GetSystemStatusRequestObject, + request gen.GetSystemStatusRequestObject, +) (gen.GetSystemStatusResponseObject, error) { + if request.Params.TargetHostname != nil { + th := struct { + TargetHostname string `validate:"min=1"` + }{TargetHostname: *request.Params.TargetHostname} + if errMsg, ok := validation.Struct(th); !ok { + return gen.GetSystemStatus400JSONResponse{Error: &errMsg}, nil + } + } + + hostname := job.AnyHost + if request.Params.TargetHostname != nil { + hostname = *request.Params.TargetHostname + } + + if hostname == job.BroadcastHost { + return s.getSystemStatusAll(ctx) + } + + status, err := s.JobClient.QuerySystemStatus(ctx, hostname) + if err != nil { + errMsg := err.Error() + return gen.GetSystemStatus500JSONResponse{ + Error: &errMsg, + }, nil + } + + resp := buildSystemStatusResponse(status) + + return gen.GetSystemStatus200JSONResponse(*resp), nil +} + +// getSystemStatusAll handles _all broadcast for system status. +func (s *System) getSystemStatusAll( + ctx context.Context, ) (gen.GetSystemStatusResponseObject, error) { - status, err := s.JobClient.QuerySystemStatusAny(ctx) + results, err := s.JobClient.QuerySystemStatusAll(ctx) if err != nil { errMsg := err.Error() return gen.GetSystemStatus500JSONResponse{ @@ -41,6 +94,18 @@ func (s *System) GetSystemStatus( }, nil } + var responses []gen.SystemStatusResponse + for _, status := range results { + responses = append(responses, *buildSystemStatusResponse(status)) + } + + return systemStatusMultiResponse{Results: responses}, nil +} + +// buildSystemStatusResponse converts a job.SystemStatusResponse to the API response. +func buildSystemStatusResponse( + status *job.SystemStatusResponse, +) *gen.SystemStatusResponse { disks := make([]gen.DiskResponse, 0, len(status.DiskUsage)) for _, d := range status.DiskUsage { disk := gen.DiskResponse{ @@ -52,7 +117,7 @@ func (s *System) GetSystemStatus( disks = append(disks, disk) } - resp := gen.GetSystemStatus200JSONResponse{ + resp := gen.SystemStatusResponse{ Hostname: status.Hostname, Uptime: formatDuration(status.Uptime), Disks: disks, @@ -81,7 +146,7 @@ func (s *System) GetSystemStatus( } } - return resp, nil + return &resp } func formatDuration( diff --git a/internal/api/system/system_status_get_integration_test.go b/internal/api/system/system_status_get_integration_test.go index ab007be8..c56bb079 100644 --- a/internal/api/system/system_status_get_integration_test.go +++ b/internal/api/system/system_status_get_integration_test.go @@ -77,7 +77,7 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QuerySystemStatusAny(gomock.Any()). + QuerySystemStatus(gomock.Any(), job.AnyHost). Return(&job.SystemStatusResponse{ Hostname: "default-hostname", Uptime: 5 * time.Hour, @@ -142,7 +142,7 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QuerySystemStatusAny(gomock.Any()). + QuerySystemStatus(gomock.Any(), job.AnyHost). Return(nil, assert.AnError) return mock }, diff --git a/internal/api/system/system_status_get_public_test.go b/internal/api/system/system_status_get_public_test.go new file mode 100644 index 00000000..265ef262 --- /dev/null +++ b/internal/api/system/system_status_get_public_test.go @@ -0,0 +1,123 @@ +// 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 system_test + +import ( + "context" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + apisystem "github.com/retr0h/osapi/internal/api/system" + "github.com/retr0h/osapi/internal/api/system/gen" + jobtypes "github.com/retr0h/osapi/internal/job" + jobmocks "github.com/retr0h/osapi/internal/job/mocks" +) + +type SystemStatusGetPublicTestSuite struct { + suite.Suite + + mockCtrl *gomock.Controller + mockJobClient *jobmocks.MockJobClient + handler *apisystem.System + ctx context.Context +} + +func (s *SystemStatusGetPublicTestSuite) SetupTest() { + s.mockCtrl = gomock.NewController(s.T()) + s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl) + s.handler = apisystem.New(s.mockJobClient) + s.ctx = context.Background() +} + +func (s *SystemStatusGetPublicTestSuite) TearDownTest() { + s.mockCtrl.Finish() +} + +func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { + tests := []struct { + name string + request gen.GetSystemStatusRequestObject + mockResult *jobtypes.SystemStatusResponse + mockError error + expectMock bool + validateFunc func(resp gen.GetSystemStatusResponseObject) + }{ + { + name: "success", + request: gen.GetSystemStatusRequestObject{}, + mockResult: &jobtypes.SystemStatusResponse{ + Hostname: "test-host", + Uptime: time.Hour, + }, + expectMock: true, + validateFunc: func(resp gen.GetSystemStatusResponseObject) { + _, ok := resp.(gen.GetSystemStatus200JSONResponse) + s.True(ok) + }, + }, + { + name: "validation error empty target_hostname", + request: gen.GetSystemStatusRequestObject{ + Params: gen.GetSystemStatusParams{TargetHostname: strPtr("")}, + }, + expectMock: false, + validateFunc: func(resp gen.GetSystemStatusResponseObject) { + r, ok := resp.(gen.GetSystemStatus400JSONResponse) + s.True(ok) + s.Require().NotNil(r.Error) + s.Contains(*r.Error, "TargetHostname") + s.Contains(*r.Error, "min") + }, + }, + { + name: "job client error", + request: gen.GetSystemStatusRequestObject{}, + mockError: assert.AnError, + expectMock: true, + validateFunc: func(resp gen.GetSystemStatusResponseObject) { + _, ok := resp.(gen.GetSystemStatus500JSONResponse) + s.True(ok) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + if tt.expectMock { + s.mockJobClient.EXPECT(). + QuerySystemStatus(gomock.Any(), gomock.Any()). + Return(tt.mockResult, tt.mockError) + } + + resp, err := s.handler.GetSystemStatus(s.ctx, tt.request) + s.NoError(err) + tt.validateFunc(resp) + }) + } +} + +func TestSystemStatusGetPublicTestSuite(t *testing.T) { + suite.Run(t, new(SystemStatusGetPublicTestSuite)) +} diff --git a/internal/client/client_public_test.go b/internal/client/client_public_test.go index 8b50313f..a1fbf751 100644 --- a/internal/client/client_public_test.go +++ b/internal/client/client_public_test.go @@ -170,7 +170,10 @@ func (s *ClientPublicTestSuite) TestRoundTrip() { c := client.New(slog.Default(), appConfig, genClient) s.NotNil(c) - _, _ = genClient.GetSystemHostnameWithResponse(context.Background()) + _, _ = genClient.GetSystemHostnameWithResponse( + context.Background(), + &gen.GetSystemHostnameParams{}, + ) s.Equal(tt.expectedHeader, receivedAuth) }) @@ -190,7 +193,7 @@ func (s *ClientPublicTestSuite) TestGetSystemHostname() { s.Run(tt.name, func() { ctx := context.Background() - resp, err := s.sut.GetSystemHostname(ctx) + resp, err := s.sut.GetSystemHostname(ctx, "_any") s.NoError(err) s.NotNil(resp) @@ -211,7 +214,7 @@ func (s *ClientPublicTestSuite) TestGetSystemStatus() { s.Run(tt.name, func() { ctx := context.Background() - resp, err := s.sut.GetSystemStatus(ctx) + resp, err := s.sut.GetSystemStatus(ctx, "_any") s.NoError(err) s.NotNil(resp) @@ -234,7 +237,7 @@ func (s *ClientPublicTestSuite) TestGetNetworkDNSByInterface() { s.Run(tt.name, func() { ctx := context.Background() - resp, err := s.sut.GetNetworkDNSByInterface(ctx, tt.interfaceName) + resp, err := s.sut.GetNetworkDNSByInterface(ctx, "_any", tt.interfaceName) s.NoError(err) s.NotNil(resp) @@ -267,7 +270,13 @@ func (s *ClientPublicTestSuite) TestPutNetworkDNS() { s.Run(tt.name, func() { ctx := context.Background() - resp, err := s.sut.PutNetworkDNS(ctx, tt.servers, tt.searchDomains, tt.interfaceName) + resp, err := s.sut.PutNetworkDNS( + ctx, + "_any", + tt.servers, + tt.searchDomains, + tt.interfaceName, + ) s.NoError(err) s.NotNil(resp) @@ -290,7 +299,147 @@ func (s *ClientPublicTestSuite) TestPostNetworkPing() { s.Run(tt.name, func() { ctx := context.Background() - resp, err := s.sut.PostNetworkPing(ctx, tt.target) + resp, err := s.sut.PostNetworkPing(ctx, "_any", tt.target) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestPostJob() { + tests := []struct { + name string + operation map[string]interface{} + targetHostname string + }{ + { + name: "creates job with operation and target", + operation: map[string]interface{}{"type": "system.hostname.get"}, + targetHostname: "_any", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.PostJob(ctx, tt.operation, tt.targetHostname) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestGetJobByID() { + tests := []struct { + name string + id string + }{ + { + name: "returns job detail response", + id: "test-job-id", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.GetJobByID(ctx, tt.id) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestDeleteJobByID() { + tests := []struct { + name string + id string + }{ + { + name: "returns delete response", + id: "test-job-id", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.DeleteJobByID(ctx, tt.id) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestGetJobs() { + tests := []struct { + name string + status string + }{ + { + name: "returns jobs without filter", + status: "", + }, + { + name: "returns jobs with status filter", + status: "completed", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.GetJobs(ctx, tt.status) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestGetJobQueueStats() { + tests := []struct { + name string + }{ + { + name: "returns queue stats response", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.GetJobQueueStats(ctx) + + s.NoError(err) + s.NotNil(resp) + }) + } +} + +func (s *ClientPublicTestSuite) TestGetJobWorkers() { + tests := []struct { + name string + }{ + { + name: "returns workers response", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + ctx := context.Background() + + resp, err := s.sut.GetJobWorkers(ctx) s.NoError(err) s.NotNil(resp) diff --git a/internal/client/gen/api.yaml b/internal/client/gen/api.yaml index a43325bf..409c2bb7 100644 --- a/internal/client/gen/api.yaml +++ b/internal/client/gen/api.yaml @@ -198,6 +198,41 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + /job/workers: + servers: [] + get: + summary: List active workers + description: Discover all active workers in the fleet by broadcasting a hostname query. + tags: + - Job_Management_API_job_operations + security: + - BearerAuth: + - read + responses: + '200': + description: List of active workers. + content: + application/json: + schema: + $ref: '#/components/schemas/ListWorkersResponse' + '401': + description: Unauthorized - API key required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '403': + description: Forbidden - Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: Error discovering workers. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' /job/{id}: servers: [] get: @@ -298,6 +333,15 @@ paths: security: - BearerAuth: - write + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). requestBody: description: The server to ping. required: true @@ -371,6 +415,14 @@ paths: description: > The name of the network interface to retrieve DNS configuration for. Must only contain letters and numbers. + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': description: List of DNS servers. @@ -413,6 +465,15 @@ paths: security: - BearerAuth: - write + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). requestBody: required: true content: @@ -458,6 +519,15 @@ paths: security: - BearerAuth: - read + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': description: A JSON object containing the system's status information. @@ -493,6 +563,15 @@ paths: security: - BearerAuth: - read + parameters: + - name: target_hostname + in: query + required: false + schema: + type: string + default: _any + description: > + Target hostname for routing (_any, _all, or specific hostname). responses: '200': description: A JSON object containing the system's hostname. @@ -610,6 +689,32 @@ components: updated_at: type: string description: Last update timestamp. + responses: + type: object + description: Per-worker response data for broadcast jobs. + additionalProperties: + type: object + properties: + status: + type: string + data: + description: Worker result data. + error: + type: string + hostname: + type: string + worker_states: + type: object + description: Per-worker processing state for broadcast jobs. + additionalProperties: + type: object + properties: + status: + type: string + error: + type: string + duration: + type: string QueueStatsResponse: type: object properties: @@ -630,6 +735,27 @@ components: dlq_count: type: integer description: Number of jobs in the dead letter queue. + ListWorkersResponse: + type: object + properties: + workers: + type: array + items: + $ref: '#/components/schemas/WorkerInfo' + total: + type: integer + description: Total number of active workers. + required: + - workers + - total + WorkerInfo: + type: object + properties: + hostname: + type: string + description: The hostname of the worker. + required: + - hostname PingResponse: type: object properties: diff --git a/internal/client/gen/client.gen.go b/internal/client/gen/client.gen.go index f440c951..ef69c9ef 100644 --- a/internal/client/gen/client.gen.go +++ b/internal/client/gen/client.gen.go @@ -118,6 +118,15 @@ type JobDetailResponse struct { // Operation The operation data. Operation *map[string]interface{} `json:"operation,omitempty"` + // Responses Per-worker response data for broadcast jobs. + Responses *map[string]struct { + // Data Worker 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"` @@ -126,6 +135,13 @@ type JobDetailResponse struct { // UpdatedAt Last update timestamp. UpdatedAt *string `json:"updated_at,omitempty"` + + // WorkerStates Per-worker processing state for broadcast jobs. + WorkerStates *map[string]struct { + Duration *string `json:"duration,omitempty"` + Error *string `json:"error,omitempty"` + Status *string `json:"status,omitempty"` + } `json:"worker_states,omitempty"` } // ListJobsResponse defines model for ListJobsResponse. @@ -136,6 +152,13 @@ type ListJobsResponse struct { TotalItems *int `json:"total_items,omitempty"` } +// ListWorkersResponse defines model for ListWorkersResponse. +type ListWorkersResponse struct { + // Total Total number of active workers. + Total int `json:"total"` + Workers []WorkerInfo `json:"workers"` +} + // LoadAverageResponse The system load averages for 1, 5, and 15 minutes. type LoadAverageResponse struct { // N15min Load average for the last 15 minutes. @@ -226,18 +249,54 @@ type SystemStatusResponse struct { Uptime string `json:"uptime"` } +// WorkerInfo defines model for WorkerInfo. +type WorkerInfo struct { + // Hostname The hostname of the worker. + Hostname string `json:"hostname"` +} + // GetJobParams defines parameters for GetJob. type GetJobParams struct { // Status Filter jobs by status (e.g., unprocessed, processing, completed, failed). Status *string `form:"status,omitempty" json:"status,omitempty"` } +// PutNetworkDNSParams defines parameters for PutNetworkDNS. +type PutNetworkDNSParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + +// GetNetworkDNSByInterfaceParams defines parameters for GetNetworkDNSByInterface. +type GetNetworkDNSByInterfaceParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + // PostNetworkPingJSONBody defines parameters for PostNetworkPing. type PostNetworkPingJSONBody struct { // Address The IP address of the server to ping. Supports both IPv4 and IPv6. Address string `json:"address" validate:"required,ip"` } +// PostNetworkPingParams defines parameters for PostNetworkPing. +type PostNetworkPingParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + +// GetSystemHostnameParams defines parameters for GetSystemHostname. +type GetSystemHostnameParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + +// GetSystemStatusParams defines parameters for GetSystemStatus. +type GetSystemStatusParams struct { + // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` +} + // PostJobJSONRequestBody defines body for PostJob for application/json ContentType. type PostJobJSONRequestBody = CreateJobRequest @@ -331,6 +390,9 @@ type ClientInterface interface { // GetJobStatus request GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetJobWorkers request + GetJobWorkers(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteJobByID request DeleteJobByID(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -338,23 +400,23 @@ type ClientInterface interface { GetJobByID(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*http.Response, error) // PutNetworkDNSWithBody request with any body - PutNetworkDNSWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + PutNetworkDNSWithBody(ctx context.Context, params *PutNetworkDNSParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - PutNetworkDNS(ctx context.Context, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + PutNetworkDNS(ctx context.Context, params *PutNetworkDNSParams, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // GetNetworkDNSByInterface request - GetNetworkDNSByInterface(ctx context.Context, interfaceName string, reqEditors ...RequestEditorFn) (*http.Response, error) + GetNetworkDNSByInterface(ctx context.Context, interfaceName string, params *GetNetworkDNSByInterfaceParams, reqEditors ...RequestEditorFn) (*http.Response, error) // PostNetworkPingWithBody request with any body - PostNetworkPingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + PostNetworkPingWithBody(ctx context.Context, params *PostNetworkPingParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) - PostNetworkPing(ctx context.Context, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + PostNetworkPing(ctx context.Context, params *PostNetworkPingParams, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) // GetSystemHostname request - GetSystemHostname(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + GetSystemHostname(ctx context.Context, params *GetSystemHostnameParams, reqEditors ...RequestEditorFn) (*http.Response, error) // GetSystemStatus request - GetSystemStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + GetSystemStatus(ctx context.Context, params *GetSystemStatusParams, reqEditors ...RequestEditorFn) (*http.Response, error) // GetVersion request GetVersion(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -408,6 +470,18 @@ func (c *Client) GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn return c.Client.Do(req) } +func (c *Client) GetJobWorkers(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetJobWorkersRequest(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 string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteJobByIDRequest(c.Server, id) if err != nil { @@ -432,8 +506,8 @@ func (c *Client) GetJobByID(ctx context.Context, id string, reqEditors ...Reques return c.Client.Do(req) } -func (c *Client) PutNetworkDNSWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPutNetworkDNSRequestWithBody(c.Server, contentType, body) +func (c *Client) PutNetworkDNSWithBody(ctx context.Context, params *PutNetworkDNSParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutNetworkDNSRequestWithBody(c.Server, params, contentType, body) if err != nil { return nil, err } @@ -444,8 +518,8 @@ func (c *Client) PutNetworkDNSWithBody(ctx context.Context, contentType string, return c.Client.Do(req) } -func (c *Client) PutNetworkDNS(ctx context.Context, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPutNetworkDNSRequest(c.Server, body) +func (c *Client) PutNetworkDNS(ctx context.Context, params *PutNetworkDNSParams, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutNetworkDNSRequest(c.Server, params, body) if err != nil { return nil, err } @@ -456,8 +530,8 @@ func (c *Client) PutNetworkDNS(ctx context.Context, body PutNetworkDNSJSONReques return c.Client.Do(req) } -func (c *Client) GetNetworkDNSByInterface(ctx context.Context, interfaceName string, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetNetworkDNSByInterfaceRequest(c.Server, interfaceName) +func (c *Client) GetNetworkDNSByInterface(ctx context.Context, interfaceName string, params *GetNetworkDNSByInterfaceParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetNetworkDNSByInterfaceRequest(c.Server, interfaceName, params) if err != nil { return nil, err } @@ -468,8 +542,8 @@ func (c *Client) GetNetworkDNSByInterface(ctx context.Context, interfaceName str return c.Client.Do(req) } -func (c *Client) PostNetworkPingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostNetworkPingRequestWithBody(c.Server, contentType, body) +func (c *Client) PostNetworkPingWithBody(ctx context.Context, params *PostNetworkPingParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNetworkPingRequestWithBody(c.Server, params, contentType, body) if err != nil { return nil, err } @@ -480,8 +554,8 @@ func (c *Client) PostNetworkPingWithBody(ctx context.Context, contentType string return c.Client.Do(req) } -func (c *Client) PostNetworkPing(ctx context.Context, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewPostNetworkPingRequest(c.Server, body) +func (c *Client) PostNetworkPing(ctx context.Context, params *PostNetworkPingParams, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostNetworkPingRequest(c.Server, params, body) if err != nil { return nil, err } @@ -492,8 +566,8 @@ func (c *Client) PostNetworkPing(ctx context.Context, body PostNetworkPingJSONRe return c.Client.Do(req) } -func (c *Client) GetSystemHostname(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetSystemHostnameRequest(c.Server) +func (c *Client) GetSystemHostname(ctx context.Context, params *GetSystemHostnameParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetSystemHostnameRequest(c.Server, params) if err != nil { return nil, err } @@ -504,8 +578,8 @@ func (c *Client) GetSystemHostname(ctx context.Context, reqEditors ...RequestEdi return c.Client.Do(req) } -func (c *Client) GetSystemStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { - req, err := NewGetSystemStatusRequest(c.Server) +func (c *Client) GetSystemStatus(ctx context.Context, params *GetSystemStatusParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetSystemStatusRequest(c.Server, params) if err != nil { return nil, err } @@ -644,6 +718,33 @@ func NewGetJobStatusRequest(server string) (*http.Request, error) { return req, nil } +// NewGetJobWorkersRequest generates requests for GetJobWorkers +func NewGetJobWorkersRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/job/workers") + 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 string) (*http.Request, error) { var err error @@ -713,18 +814,18 @@ func NewGetJobByIDRequest(server string, id string) (*http.Request, error) { } // NewPutNetworkDNSRequest calls the generic PutNetworkDNS builder with application/json body -func NewPutNetworkDNSRequest(server string, body PutNetworkDNSJSONRequestBody) (*http.Request, error) { +func NewPutNetworkDNSRequest(server string, params *PutNetworkDNSParams, body PutNetworkDNSJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewPutNetworkDNSRequestWithBody(server, "application/json", bodyReader) + return NewPutNetworkDNSRequestWithBody(server, params, "application/json", bodyReader) } // NewPutNetworkDNSRequestWithBody generates requests for PutNetworkDNS with any type of body -func NewPutNetworkDNSRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +func NewPutNetworkDNSRequestWithBody(server string, params *PutNetworkDNSParams, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -742,6 +843,28 @@ func NewPutNetworkDNSRequestWithBody(server string, contentType string, body io. return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.TargetHostname != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_hostname", runtime.ParamLocationQuery, *params.TargetHostname); 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("PUT", queryURL.String(), body) if err != nil { return nil, err @@ -753,7 +876,7 @@ func NewPutNetworkDNSRequestWithBody(server string, contentType string, body io. } // NewGetNetworkDNSByInterfaceRequest generates requests for GetNetworkDNSByInterface -func NewGetNetworkDNSByInterfaceRequest(server string, interfaceName string) (*http.Request, error) { +func NewGetNetworkDNSByInterfaceRequest(server string, interfaceName string, params *GetNetworkDNSByInterfaceParams) (*http.Request, error) { var err error var pathParam0 string @@ -778,6 +901,28 @@ func NewGetNetworkDNSByInterfaceRequest(server string, interfaceName string) (*h return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.TargetHostname != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_hostname", runtime.ParamLocationQuery, *params.TargetHostname); 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 @@ -787,18 +932,18 @@ func NewGetNetworkDNSByInterfaceRequest(server string, interfaceName string) (*h } // NewPostNetworkPingRequest calls the generic PostNetworkPing builder with application/json body -func NewPostNetworkPingRequest(server string, body PostNetworkPingJSONRequestBody) (*http.Request, error) { +func NewPostNetworkPingRequest(server string, params *PostNetworkPingParams, body PostNetworkPingJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewPostNetworkPingRequestWithBody(server, "application/json", bodyReader) + return NewPostNetworkPingRequestWithBody(server, params, "application/json", bodyReader) } // NewPostNetworkPingRequestWithBody generates requests for PostNetworkPing with any type of body -func NewPostNetworkPingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +func NewPostNetworkPingRequestWithBody(server string, params *PostNetworkPingParams, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -816,6 +961,28 @@ func NewPostNetworkPingRequestWithBody(server string, contentType string, body i return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.TargetHostname != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_hostname", runtime.ParamLocationQuery, *params.TargetHostname); 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 @@ -827,7 +994,7 @@ func NewPostNetworkPingRequestWithBody(server string, contentType string, body i } // NewGetSystemHostnameRequest generates requests for GetSystemHostname -func NewGetSystemHostnameRequest(server string) (*http.Request, error) { +func NewGetSystemHostnameRequest(server string, params *GetSystemHostnameParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -845,6 +1012,28 @@ func NewGetSystemHostnameRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.TargetHostname != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_hostname", runtime.ParamLocationQuery, *params.TargetHostname); 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 @@ -854,7 +1043,7 @@ func NewGetSystemHostnameRequest(server string) (*http.Request, error) { } // NewGetSystemStatusRequest generates requests for GetSystemStatus -func NewGetSystemStatusRequest(server string) (*http.Request, error) { +func NewGetSystemStatusRequest(server string, params *GetSystemStatusParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -872,6 +1061,28 @@ func NewGetSystemStatusRequest(server string) (*http.Request, error) { return nil, err } + if params != nil { + queryValues := queryURL.Query() + + if params.TargetHostname != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "target_hostname", runtime.ParamLocationQuery, *params.TargetHostname); 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 @@ -961,6 +1172,9 @@ type ClientWithResponsesInterface interface { // GetJobStatusWithResponse request GetJobStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobStatusResponse, error) + // GetJobWorkersWithResponse request + GetJobWorkersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobWorkersResponse, error) + // DeleteJobByIDWithResponse request DeleteJobByIDWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error) @@ -968,23 +1182,23 @@ type ClientWithResponsesInterface interface { GetJobByIDWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*GetJobByIDResponse, error) // PutNetworkDNSWithBodyWithResponse request with any body - PutNetworkDNSWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) + PutNetworkDNSWithBodyWithResponse(ctx context.Context, params *PutNetworkDNSParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) - PutNetworkDNSWithResponse(ctx context.Context, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) + PutNetworkDNSWithResponse(ctx context.Context, params *PutNetworkDNSParams, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) // GetNetworkDNSByInterfaceWithResponse request - GetNetworkDNSByInterfaceWithResponse(ctx context.Context, interfaceName string, reqEditors ...RequestEditorFn) (*GetNetworkDNSByInterfaceResponse, error) + GetNetworkDNSByInterfaceWithResponse(ctx context.Context, interfaceName string, params *GetNetworkDNSByInterfaceParams, reqEditors ...RequestEditorFn) (*GetNetworkDNSByInterfaceResponse, error) // PostNetworkPingWithBodyWithResponse request with any body - PostNetworkPingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) + PostNetworkPingWithBodyWithResponse(ctx context.Context, params *PostNetworkPingParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) - PostNetworkPingWithResponse(ctx context.Context, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) + PostNetworkPingWithResponse(ctx context.Context, params *PostNetworkPingParams, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) // GetSystemHostnameWithResponse request - GetSystemHostnameWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSystemHostnameResponse, error) + GetSystemHostnameWithResponse(ctx context.Context, params *GetSystemHostnameParams, reqEditors ...RequestEditorFn) (*GetSystemHostnameResponse, error) // GetSystemStatusWithResponse request - GetSystemStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSystemStatusResponse, error) + GetSystemStatusWithResponse(ctx context.Context, params *GetSystemStatusParams, reqEditors ...RequestEditorFn) (*GetSystemStatusResponse, error) // GetVersionWithResponse request GetVersionWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetVersionResponse, error) @@ -1066,6 +1280,31 @@ func (r GetJobStatusResponse) StatusCode() int { return 0 } +type GetJobWorkersResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ListWorkersResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetJobWorkersResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetJobWorkersResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type DeleteJobByIDResponse struct { Body []byte HTTPResponse *http.Response @@ -1301,6 +1540,15 @@ func (c *ClientWithResponses) GetJobStatusWithResponse(ctx context.Context, reqE return ParseGetJobStatusResponse(rsp) } +// GetJobWorkersWithResponse request returning *GetJobWorkersResponse +func (c *ClientWithResponses) GetJobWorkersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobWorkersResponse, error) { + rsp, err := c.GetJobWorkers(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetJobWorkersResponse(rsp) +} + // DeleteJobByIDWithResponse request returning *DeleteJobByIDResponse func (c *ClientWithResponses) DeleteJobByIDWithResponse(ctx context.Context, id string, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error) { rsp, err := c.DeleteJobByID(ctx, id, reqEditors...) @@ -1320,16 +1568,16 @@ func (c *ClientWithResponses) GetJobByIDWithResponse(ctx context.Context, id str } // PutNetworkDNSWithBodyWithResponse request with arbitrary body returning *PutNetworkDNSResponse -func (c *ClientWithResponses) PutNetworkDNSWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) { - rsp, err := c.PutNetworkDNSWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) PutNetworkDNSWithBodyWithResponse(ctx context.Context, params *PutNetworkDNSParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) { + rsp, err := c.PutNetworkDNSWithBody(ctx, params, contentType, body, reqEditors...) if err != nil { return nil, err } return ParsePutNetworkDNSResponse(rsp) } -func (c *ClientWithResponses) PutNetworkDNSWithResponse(ctx context.Context, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) { - rsp, err := c.PutNetworkDNS(ctx, body, reqEditors...) +func (c *ClientWithResponses) PutNetworkDNSWithResponse(ctx context.Context, params *PutNetworkDNSParams, body PutNetworkDNSJSONRequestBody, reqEditors ...RequestEditorFn) (*PutNetworkDNSResponse, error) { + rsp, err := c.PutNetworkDNS(ctx, params, body, reqEditors...) if err != nil { return nil, err } @@ -1337,8 +1585,8 @@ func (c *ClientWithResponses) PutNetworkDNSWithResponse(ctx context.Context, bod } // GetNetworkDNSByInterfaceWithResponse request returning *GetNetworkDNSByInterfaceResponse -func (c *ClientWithResponses) GetNetworkDNSByInterfaceWithResponse(ctx context.Context, interfaceName string, reqEditors ...RequestEditorFn) (*GetNetworkDNSByInterfaceResponse, error) { - rsp, err := c.GetNetworkDNSByInterface(ctx, interfaceName, reqEditors...) +func (c *ClientWithResponses) GetNetworkDNSByInterfaceWithResponse(ctx context.Context, interfaceName string, params *GetNetworkDNSByInterfaceParams, reqEditors ...RequestEditorFn) (*GetNetworkDNSByInterfaceResponse, error) { + rsp, err := c.GetNetworkDNSByInterface(ctx, interfaceName, params, reqEditors...) if err != nil { return nil, err } @@ -1346,16 +1594,16 @@ func (c *ClientWithResponses) GetNetworkDNSByInterfaceWithResponse(ctx context.C } // PostNetworkPingWithBodyWithResponse request with arbitrary body returning *PostNetworkPingResponse -func (c *ClientWithResponses) PostNetworkPingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) { - rsp, err := c.PostNetworkPingWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) PostNetworkPingWithBodyWithResponse(ctx context.Context, params *PostNetworkPingParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) { + rsp, err := c.PostNetworkPingWithBody(ctx, params, contentType, body, reqEditors...) if err != nil { return nil, err } return ParsePostNetworkPingResponse(rsp) } -func (c *ClientWithResponses) PostNetworkPingWithResponse(ctx context.Context, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) { - rsp, err := c.PostNetworkPing(ctx, body, reqEditors...) +func (c *ClientWithResponses) PostNetworkPingWithResponse(ctx context.Context, params *PostNetworkPingParams, body PostNetworkPingJSONRequestBody, reqEditors ...RequestEditorFn) (*PostNetworkPingResponse, error) { + rsp, err := c.PostNetworkPing(ctx, params, body, reqEditors...) if err != nil { return nil, err } @@ -1363,8 +1611,8 @@ func (c *ClientWithResponses) PostNetworkPingWithResponse(ctx context.Context, b } // GetSystemHostnameWithResponse request returning *GetSystemHostnameResponse -func (c *ClientWithResponses) GetSystemHostnameWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSystemHostnameResponse, error) { - rsp, err := c.GetSystemHostname(ctx, reqEditors...) +func (c *ClientWithResponses) GetSystemHostnameWithResponse(ctx context.Context, params *GetSystemHostnameParams, reqEditors ...RequestEditorFn) (*GetSystemHostnameResponse, error) { + rsp, err := c.GetSystemHostname(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -1372,8 +1620,8 @@ func (c *ClientWithResponses) GetSystemHostnameWithResponse(ctx context.Context, } // GetSystemStatusWithResponse request returning *GetSystemStatusResponse -func (c *ClientWithResponses) GetSystemStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetSystemStatusResponse, error) { - rsp, err := c.GetSystemStatus(ctx, reqEditors...) +func (c *ClientWithResponses) GetSystemStatusWithResponse(ctx context.Context, params *GetSystemStatusParams, reqEditors ...RequestEditorFn) (*GetSystemStatusResponse, error) { + rsp, err := c.GetSystemStatus(ctx, params, reqEditors...) if err != nil { return nil, err } @@ -1537,6 +1785,53 @@ func ParseGetJobStatusResponse(rsp *http.Response) (*GetJobStatusResponse, error return response, nil } +// ParseGetJobWorkersResponse parses an HTTP response from a GetJobWorkersWithResponse call +func ParseGetJobWorkersResponse(rsp *http.Response) (*GetJobWorkersResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetJobWorkersResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListWorkersResponse + 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) diff --git a/internal/client/handler.go b/internal/client/handler.go index 5635440d..271fa681 100644 --- a/internal/client/handler.go +++ b/internal/client/handler.go @@ -30,6 +30,45 @@ import ( type CombinedHandler interface { NetworkHandler SystemHandler + JobHandler +} + +// JobHandler defines an interface for interacting with Job client operations. +type JobHandler interface { + // PostJob creates a new job via the REST API. + PostJob( + ctx context.Context, + operation map[string]interface{}, + targetHostname string, + ) (*gen.PostJobResponse, error) + + // GetJobByID retrieves a specific job by ID via the REST API. + GetJobByID( + ctx context.Context, + id string, + ) (*gen.GetJobByIDResponse, error) + + // DeleteJobByID deletes a specific job by ID via the REST API. + DeleteJobByID( + ctx context.Context, + id string, + ) (*gen.DeleteJobByIDResponse, error) + + // GetJobs retrieves jobs, optionally filtered by status, via the REST API. + GetJobs( + ctx context.Context, + status string, + ) (*gen.GetJobResponse, error) + + // GetJobQueueStats retrieves queue statistics via the REST API. + GetJobQueueStats( + ctx context.Context, + ) (*gen.GetJobStatusResponse, error) + + // GetJobWorkers retrieves active workers via the REST API. + GetJobWorkers( + ctx context.Context, + ) (*gen.GetJobWorkersResponse, error) } // NetworkHandler defines an interface for interacting with Network client operations. @@ -37,12 +76,14 @@ type NetworkHandler interface { // GetNetworkDNSByInterface get the network dns get API endpoint. GetNetworkDNSByInterface( ctx context.Context, - _ string, + hostname string, + interfaceName string, ) (*gen.GetNetworkDNSByInterfaceResponse, error) // PutNetworkDNS put the network dns put API endpoint. PutNetworkDNS( ctx context.Context, + hostname string, servers []string, searchDomains []string, interfaceName string, @@ -50,6 +91,7 @@ type NetworkHandler interface { // PostNetworkPing post the network ping API endpoint. PostNetworkPing( ctx context.Context, + hostname string, address string, ) (*gen.PostNetworkPingResponse, error) } @@ -59,9 +101,11 @@ type SystemHandler interface { // GetSystemStatus get the system status API endpoint. GetSystemStatus( ctx context.Context, + hostname string, ) (*gen.GetSystemStatusResponse, error) // GetSystemHostname get the system hostname API endpoint. GetSystemHostname( ctx context.Context, + hostname string, ) (*gen.GetSystemHostnameResponse, error) } diff --git a/internal/client/job_delete_by_id.go b/internal/client/job_delete_by_id.go new file mode 100644 index 00000000..2703df30 --- /dev/null +++ b/internal/client/job_delete_by_id.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// DeleteJobByID deletes a specific job by ID via the REST API. +func (c *Client) DeleteJobByID( + ctx context.Context, + id string, +) (*gen.DeleteJobByIDResponse, error) { + return c.Client.DeleteJobByIDWithResponse(ctx, id) +} diff --git a/internal/client/job_get_by_id.go b/internal/client/job_get_by_id.go new file mode 100644 index 00000000..f2c67ea6 --- /dev/null +++ b/internal/client/job_get_by_id.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// GetJobByID retrieves a specific job by ID via the REST API. +func (c *Client) GetJobByID( + ctx context.Context, + id string, +) (*gen.GetJobByIDResponse, error) { + return c.Client.GetJobByIDWithResponse(ctx, id) +} diff --git a/internal/client/job_list.go b/internal/client/job_list.go new file mode 100644 index 00000000..f6f9aa0f --- /dev/null +++ b/internal/client/job_list.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// GetJobs retrieves jobs, optionally filtered by status, via the REST API. +func (c *Client) GetJobs( + ctx context.Context, + status string, +) (*gen.GetJobResponse, error) { + params := &gen.GetJobParams{} + if status != "" { + params.Status = &status + } + + return c.Client.GetJobWithResponse(ctx, params) +} diff --git a/internal/client/job_post.go b/internal/client/job_post.go new file mode 100644 index 00000000..b0cd9b54 --- /dev/null +++ b/internal/client/job_post.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// PostJob creates a new job via the REST API. +func (c *Client) PostJob( + ctx context.Context, + operation map[string]interface{}, + targetHostname string, +) (*gen.PostJobResponse, error) { + body := gen.CreateJobRequest{ + Operation: operation, + TargetHostname: targetHostname, + } + + return c.Client.PostJobWithResponse(ctx, body) +} diff --git a/internal/client/job_status_get.go b/internal/client/job_status_get.go new file mode 100644 index 00000000..37f3be73 --- /dev/null +++ b/internal/client/job_status_get.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// GetJobQueueStats retrieves queue statistics via the REST API. +func (c *Client) GetJobQueueStats( + ctx context.Context, +) (*gen.GetJobStatusResponse, error) { + return c.Client.GetJobStatusWithResponse(ctx) +} diff --git a/internal/client/job_workers_get.go b/internal/client/job_workers_get.go new file mode 100644 index 00000000..c94db00c --- /dev/null +++ b/internal/client/job_workers_get.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package client + +import ( + "context" + + "github.com/retr0h/osapi/internal/client/gen" +) + +// GetJobWorkers retrieves active workers via the REST API. +func (c *Client) GetJobWorkers( + ctx context.Context, +) (*gen.GetJobWorkersResponse, error) { + return c.Client.GetJobWorkersWithResponse(ctx) +} diff --git a/internal/client/network_dns_get.go b/internal/client/network_dns_get.go index 36582c2b..0ad563ab 100644 --- a/internal/client/network_dns_get.go +++ b/internal/client/network_dns_get.go @@ -29,7 +29,12 @@ import ( // GetNetworkDNSByInterface get the network dns get API endpoint. func (c *Client) GetNetworkDNSByInterface( ctx context.Context, + hostname string, interfaceName string, ) (*gen.GetNetworkDNSByInterfaceResponse, error) { - return c.Client.GetNetworkDNSByInterfaceWithResponse(ctx, interfaceName) + params := &gen.GetNetworkDNSByInterfaceParams{ + TargetHostname: &hostname, + } + + return c.Client.GetNetworkDNSByInterfaceWithResponse(ctx, interfaceName, params) } diff --git a/internal/client/network_dns_put.go b/internal/client/network_dns_put.go index d38b23da..c70c2dce 100644 --- a/internal/client/network_dns_put.go +++ b/internal/client/network_dns_put.go @@ -29,10 +29,15 @@ import ( // PutNetworkDNS put the network dns put API endpoint. func (c *Client) PutNetworkDNS( ctx context.Context, + hostname string, servers []string, searchDomains []string, interfaceName string, ) (*gen.PutNetworkDNSResponse, error) { + params := &gen.PutNetworkDNSParams{ + TargetHostname: &hostname, + } + body := gen.DNSConfigUpdateRequest{} body.InterfaceName = interfaceName @@ -45,5 +50,5 @@ func (c *Client) PutNetworkDNS( body.SearchDomains = &searchDomains } - return c.Client.PutNetworkDNSWithResponse(ctx, body) + return c.Client.PutNetworkDNSWithResponse(ctx, params, body) } diff --git a/internal/client/network_ping_post.go b/internal/client/network_ping_post.go index 452ab0c4..f0755623 100644 --- a/internal/client/network_ping_post.go +++ b/internal/client/network_ping_post.go @@ -29,11 +29,16 @@ import ( // PostNetworkPing post the network ping API endpoint. func (c *Client) PostNetworkPing( ctx context.Context, + hostname string, address string, ) (*gen.PostNetworkPingResponse, error) { + params := &gen.PostNetworkPingParams{ + TargetHostname: &hostname, + } + body := gen.PostNetworkPingJSONRequestBody{ Address: address, } - return c.Client.PostNetworkPingWithResponse(ctx, body) + return c.Client.PostNetworkPingWithResponse(ctx, params, body) } diff --git a/internal/client/system_hostname_get.go b/internal/client/system_hostname_get.go index 9bf442f9..4ee11469 100644 --- a/internal/client/system_hostname_get.go +++ b/internal/client/system_hostname_get.go @@ -29,6 +29,11 @@ import ( // GetSystemHostname get the system hostname API endpoint. func (c *Client) GetSystemHostname( ctx context.Context, + hostname string, ) (*gen.GetSystemHostnameResponse, error) { - return c.Client.GetSystemHostnameWithResponse(ctx) + params := &gen.GetSystemHostnameParams{ + TargetHostname: &hostname, + } + + return c.Client.GetSystemHostnameWithResponse(ctx, params) } diff --git a/internal/client/system_status_get.go b/internal/client/system_status_get.go index 7d1c1d44..59e2b4a9 100644 --- a/internal/client/system_status_get.go +++ b/internal/client/system_status_get.go @@ -29,6 +29,11 @@ import ( // GetSystemStatus get the system status API endpoint. func (c *Client) GetSystemStatus( ctx context.Context, + hostname string, ) (*gen.GetSystemStatusResponse, error) { - return c.Client.GetSystemStatusWithResponse(ctx) + params := &gen.GetSystemStatusParams{ + TargetHostname: &hostname, + } + + return c.Client.GetSystemStatusWithResponse(ctx, params) } diff --git a/internal/job/client/client.go b/internal/job/client/client.go index 3a1ee18e..34dd2630 100644 --- a/internal/job/client/client.go +++ b/internal/job/client/client.go @@ -158,8 +158,15 @@ func (c *Client) publishAndWait( } } +// broadcastQuietPeriod is the duration of silence after the last response +// before we consider the broadcast complete. If no new responses arrive +// within this window, we return whatever we've collected. +const broadcastQuietPeriod = 3 * time.Second + // publishAndCollect stores a job in KV, publishes a notification, and collects -// all worker responses within the timeout window. Used for broadcast (_all) jobs. +// worker responses using a quiet period strategy. After each response, a short +// timer resets. When the timer expires with no new responses, the collected +// results are returned. The overall timeout acts as a safety net. func (c *Client) publishAndCollect( ctx context.Context, subject string, @@ -219,9 +226,16 @@ func (c *Client) publishAndCollect( }() responses := make(map[string]*job.Response) + + // Overall timeout as safety net timeoutCtx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() + // Quiet period timer — starts at the full timeout (waiting for first response), + // then resets to the short quiet period after each response arrives. + quietTimer := time.NewTimer(c.timeout) + defer quietTimer.Stop() + for { select { case <-timeoutCtx.Done(): @@ -231,6 +245,13 @@ func (c *Client) publishAndCollect( ) } return responses, nil + case <-quietTimer.C: + if len(responses) == 0 { + return nil, fmt.Errorf( + "timeout waiting for broadcast responses: no workers responded", + ) + } + return responses, nil case entry := <-watcher.Updates(): if entry == nil { continue @@ -257,6 +278,10 @@ func (c *Client) publishAndCollect( ) responses[hostname] = &response + + // Reset quiet period — if no more responses arrive within + // this window, we're done collecting. + quietTimer.Reset(broadcastQuietPeriod) } } } diff --git a/internal/job/client/modify.go b/internal/job/client/modify.go index aa14615c..cd90b8a7 100644 --- a/internal/job/client/modify.go +++ b/internal/job/client/modify.go @@ -70,3 +70,40 @@ func (c *Client) ModifyNetworkDNSAny( ) error { return c.ModifyNetworkDNS(ctx, job.AnyHost, servers, searchDomains, iface) } + +// ModifyNetworkDNSAll modifies DNS configuration on all hosts. +func (c *Client) ModifyNetworkDNSAll( + ctx context.Context, + servers []string, + searchDomains []string, + iface string, +) (map[string]error, error) { + data, _ := json.Marshal(map[string]interface{}{ + "servers": servers, + "search_domains": searchDomains, + "interface": iface, + }) + req := &job.Request{ + Type: job.TypeModify, + Category: "network", + Operation: "dns.update", + Data: json.RawMessage(data), + } + + subject := job.BuildModifySubject(job.BroadcastHost) + responses, err := c.publishAndCollect(ctx, subject, req) + if err != nil { + return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) + } + + results := make(map[string]error) + for hostname, resp := range responses { + if resp.Status == "failed" { + results[hostname] = fmt.Errorf("job failed: %s", resp.Error) + } else { + results[hostname] = nil + } + } + + return results, nil +} diff --git a/internal/job/client/query.go b/internal/job/client/query.go index aa4256d5..0ce4909f 100644 --- a/internal/job/client/query.go +++ b/internal/job/client/query.go @@ -212,3 +212,135 @@ func (c *Client) QueryNetworkPingAny( ) (*ping.Result, error) { return c.QueryNetworkPing(ctx, job.AnyHost, address) } + +// QuerySystemHostnameAll queries hostname from all hosts. +func (c *Client) QuerySystemHostnameAll( + ctx context.Context, +) (map[string]string, error) { + req := &job.Request{ + Type: job.TypeQuery, + Category: "system", + Operation: "hostname.get", + Data: json.RawMessage(`{}`), + } + + subject := job.BuildQuerySubject(job.BroadcastHost) + responses, err := c.publishAndCollect(ctx, subject, req) + if err != nil { + return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) + } + + results := make(map[string]string) + for hostname, resp := range responses { + if resp.Status == "failed" { + continue + } + + var result struct { + Hostname string `json:"hostname"` + } + if err := json.Unmarshal(resp.Data, &result); err != nil { + continue + } + + results[hostname] = result.Hostname + } + + return results, nil +} + +// QueryNetworkDNSAll queries DNS configuration from all hosts. +func (c *Client) QueryNetworkDNSAll( + ctx context.Context, + iface string, +) (map[string]*dns.Config, error) { + data, _ := json.Marshal(map[string]interface{}{ + "interface": iface, + }) + req := &job.Request{ + Type: job.TypeQuery, + Category: "network", + Operation: "dns.get", + Data: json.RawMessage(data), + } + + subject := job.BuildQuerySubject(job.BroadcastHost) + responses, err := c.publishAndCollect(ctx, subject, req) + if err != nil { + return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) + } + + results := make(map[string]*dns.Config) + for hostname, resp := range responses { + if resp.Status == "failed" { + continue + } + + var result dns.Config + if err := json.Unmarshal(resp.Data, &result); err != nil { + continue + } + + results[hostname] = &result + } + + return results, nil +} + +// QueryNetworkPingAll pings a host from all hosts. +func (c *Client) QueryNetworkPingAll( + ctx context.Context, + address string, +) (map[string]*ping.Result, error) { + data, _ := json.Marshal(map[string]interface{}{ + "address": address, + }) + req := &job.Request{ + Type: job.TypeQuery, + Category: "network", + Operation: "ping.do", + Data: json.RawMessage(data), + } + + subject := job.BuildQuerySubject(job.BroadcastHost) + responses, err := c.publishAndCollect(ctx, subject, req) + if err != nil { + return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) + } + + results := make(map[string]*ping.Result) + for hostname, resp := range responses { + if resp.Status == "failed" { + continue + } + + var result ping.Result + if err := json.Unmarshal(resp.Data, &result); err != nil { + continue + } + + results[hostname] = &result + } + + return results, nil +} + +// ListWorkers discovers all active workers by broadcasting a hostname query +// and collecting responses. Each responding worker is returned as a WorkerInfo. +func (c *Client) ListWorkers( + ctx context.Context, +) ([]job.WorkerInfo, error) { + hostnames, err := c.QuerySystemHostnameAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to discover workers: %w", err) + } + + workers := make([]job.WorkerInfo, 0, len(hostnames)) + for _, hostname := range hostnames { + workers = append(workers, job.WorkerInfo{ + Hostname: hostname, + }) + } + + return workers, nil +} diff --git a/internal/job/client/types.go b/internal/job/client/types.go index 10a239e5..c3f1c8a0 100644 --- a/internal/job/client/types.go +++ b/internal/job/client/types.go @@ -66,11 +66,18 @@ type JobClient interface { ctx context.Context, hostname string, ) (string, error) + QuerySystemHostnameAll( + ctx context.Context, + ) (map[string]string, error) QueryNetworkDNS( ctx context.Context, hostname string, iface string, ) (*dns.Config, error) + QueryNetworkDNSAll( + ctx context.Context, + iface string, + ) (map[string]*dns.Config, error) // Modify operations ModifyNetworkDNS( @@ -86,6 +93,12 @@ type JobClient interface { searchDomains []string, iface string, ) error + ModifyNetworkDNSAll( + ctx context.Context, + servers []string, + searchDomains []string, + iface string, + ) (map[string]error, error) QueryNetworkPing( ctx context.Context, hostname string, @@ -95,6 +108,15 @@ type JobClient interface { ctx context.Context, address string, ) (*ping.Result, error) + QueryNetworkPingAll( + ctx context.Context, + address string, + ) (map[string]*ping.Result, error) + + // Worker discovery + ListWorkers( + ctx context.Context, + ) ([]job.WorkerInfo, error) // Job deletion DeleteJob( diff --git a/internal/job/mocks/job_client.gen.go b/internal/job/mocks/job_client.gen.go index f7a59081..d1b3a6ef 100644 --- a/internal/job/mocks/job_client.gen.go +++ b/internal/job/mocks/job_client.gen.go @@ -157,6 +157,21 @@ func (mr *MockJobClientMockRecorder) ListJobs(arg0, arg1 interface{}) *gomock.Ca return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListJobs", reflect.TypeOf((*MockJobClient)(nil).ListJobs), arg0, arg1) } +// ListWorkers mocks base method. +func (m *MockJobClient) ListWorkers(arg0 context.Context) ([]job.WorkerInfo, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListWorkers", arg0) + ret0, _ := ret[0].([]job.WorkerInfo) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListWorkers indicates an expected call of ListWorkers. +func (mr *MockJobClientMockRecorder) ListWorkers(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListWorkers", reflect.TypeOf((*MockJobClient)(nil).ListWorkers), arg0) +} + // ModifyNetworkDNS mocks base method. func (m *MockJobClient) ModifyNetworkDNS(arg0 context.Context, arg1 string, arg2, arg3 []string, arg4 string) error { m.ctrl.T.Helper() @@ -171,6 +186,21 @@ func (mr *MockJobClientMockRecorder) ModifyNetworkDNS(arg0, arg1, arg2, arg3, ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ModifyNetworkDNS", reflect.TypeOf((*MockJobClient)(nil).ModifyNetworkDNS), arg0, arg1, arg2, arg3, arg4) } +// ModifyNetworkDNSAll mocks base method. +func (m *MockJobClient) ModifyNetworkDNSAll(arg0 context.Context, arg1, arg2 []string, arg3 string) (map[string]error, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ModifyNetworkDNSAll", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(map[string]error) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ModifyNetworkDNSAll indicates an expected call of ModifyNetworkDNSAll. +func (mr *MockJobClientMockRecorder) ModifyNetworkDNSAll(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ModifyNetworkDNSAll", reflect.TypeOf((*MockJobClient)(nil).ModifyNetworkDNSAll), arg0, arg1, arg2, arg3) +} + // ModifyNetworkDNSAny mocks base method. func (m *MockJobClient) ModifyNetworkDNSAny(arg0 context.Context, arg1, arg2 []string, arg3 string) error { m.ctrl.T.Helper() @@ -200,6 +230,21 @@ func (mr *MockJobClientMockRecorder) QueryNetworkDNS(arg0, arg1, arg2 interface{ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkDNS", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkDNS), arg0, arg1, arg2) } +// QueryNetworkDNSAll mocks base method. +func (m *MockJobClient) QueryNetworkDNSAll(arg0 context.Context, arg1 string) (map[string]*dns.Config, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryNetworkDNSAll", arg0, arg1) + ret0, _ := ret[0].(map[string]*dns.Config) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryNetworkDNSAll indicates an expected call of QueryNetworkDNSAll. +func (mr *MockJobClientMockRecorder) QueryNetworkDNSAll(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkDNSAll", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkDNSAll), arg0, arg1) +} + // QueryNetworkPing mocks base method. func (m *MockJobClient) QueryNetworkPing(arg0 context.Context, arg1, arg2 string) (*ping.Result, error) { m.ctrl.T.Helper() @@ -215,6 +260,21 @@ func (mr *MockJobClientMockRecorder) QueryNetworkPing(arg0, arg1, arg2 interface return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkPing", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkPing), arg0, arg1, arg2) } +// QueryNetworkPingAll mocks base method. +func (m *MockJobClient) QueryNetworkPingAll(arg0 context.Context, arg1 string) (map[string]*ping.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryNetworkPingAll", arg0, arg1) + ret0, _ := ret[0].(map[string]*ping.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryNetworkPingAll indicates an expected call of QueryNetworkPingAll. +func (mr *MockJobClientMockRecorder) QueryNetworkPingAll(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkPingAll", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkPingAll), arg0, arg1) +} + // QueryNetworkPingAny mocks base method. func (m *MockJobClient) QueryNetworkPingAny(arg0 context.Context, arg1 string) (*ping.Result, error) { m.ctrl.T.Helper() @@ -245,6 +305,21 @@ func (mr *MockJobClientMockRecorder) QuerySystemHostname(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemHostname", reflect.TypeOf((*MockJobClient)(nil).QuerySystemHostname), arg0, arg1) } +// QuerySystemHostnameAll mocks base method. +func (m *MockJobClient) QuerySystemHostnameAll(arg0 context.Context) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QuerySystemHostnameAll", arg0) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QuerySystemHostnameAll indicates an expected call of QuerySystemHostnameAll. +func (mr *MockJobClientMockRecorder) QuerySystemHostnameAll(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemHostnameAll", reflect.TypeOf((*MockJobClient)(nil).QuerySystemHostnameAll), arg0) +} + // QuerySystemStatus mocks base method. func (m *MockJobClient) QuerySystemStatus(arg0 context.Context, arg1 string) (*job.SystemStatusResponse, error) { m.ctrl.T.Helper() diff --git a/internal/job/types.go b/internal/job/types.go index c9c048e4..f910628c 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -218,6 +218,12 @@ type SystemShutdownData struct { Message string `json:"message,omitempty"` } +// WorkerInfo represents basic information about an active worker. +type WorkerInfo struct { + // Hostname is the hostname of the worker. + Hostname string `json:"hostname"` +} + // SystemStatusResponse aggregates system status information from multiple providers. // This represents the response for system.status.get operations in the job queue. type SystemStatusResponse struct { From b8b4e0ca2cc53c7411fd391c81ed0e8a03cadb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Tue, 17 Feb 2026 21:09:39 -0800 Subject: [PATCH 2/2] test: Add broadcast _all coverage for API and job client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add test cases for _all broadcast paths in all API handler public and integration tests, plus job client *All and ListWorkers methods. Brings API handler coverage to 100% and job client to 99.6%. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- ...k_dns_get_by_interface_integration_test.go | 18 + ...etwork_dns_get_by_interface_public_test.go | 82 ++-- ...k_dns_put_by_interface_integration_test.go | 24 +- ...etwork_dns_put_by_interface_public_test.go | 91 ++++- .../network_ping_post_integration_test.go | 29 +- .../network/network_ping_post_public_test.go | 84 ++++- .../system_hostname_get_integration_test.go | 24 +- .../system/system_hostname_get_public_test.go | 66 +++- .../system_status_get_integration_test.go | 30 +- .../system/system_status_get_public_test.go | 66 +++- internal/job/client/modify_public_test.go | 107 ++++++ internal/job/client/query_public_test.go | 352 ++++++++++++++++++ 12 files changed, 880 insertions(+), 93 deletions(-) diff --git a/internal/api/network/network_dns_get_by_interface_integration_test.go b/internal/api/network/network_dns_get_by_interface_integration_test.go index 6ed68efd..ae09b665 100644 --- a/internal/api/network/network_dns_get_by_interface_integration_test.go +++ b/internal/api/network/network_dns_get_by_interface_integration_test.go @@ -90,6 +90,24 @@ func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByIn wantCode: http.StatusBadRequest, wantContains: []string{`"error"`, "InterfaceName", "alphanum"}, }, + { + name: "when broadcast all", + path: "/network/dns/eth0?target_hostname=_all", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + QueryNetworkDNSAll(gomock.Any(), "eth0"). + Return(map[string]*dns.Config{ + "server1": { + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"8.8.8.8"`}, + }, } for _, tc := range tests { diff --git a/internal/api/network/network_dns_get_by_interface_public_test.go b/internal/api/network/network_dns_get_by_interface_public_test.go index 577701e7..a555c16f 100644 --- a/internal/api/network/network_dns_get_by_interface_public_test.go +++ b/internal/api/network/network_dns_get_by_interface_public_test.go @@ -59,19 +59,20 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() tests := []struct { name string request gen.GetNetworkDNSByInterfaceRequestObject - mockConfig *dns.Config - mockError error - expectMock bool + setupMock func() validateFunc func(resp gen.GetNetworkDNSByInterfaceResponseObject) }{ { name: "success", request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth0"}, - mockConfig: &dns.Config{ - DNSServers: []string{"192.168.1.1", "8.8.8.8"}, - SearchDomains: []string{"example.com", "local.lan"}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkDNS(gomock.Any(), gomock.Any(), "eth0"). + Return(&dns.Config{ + DNSServers: []string{"192.168.1.1", "8.8.8.8"}, + SearchDomains: []string{"example.com", "local.lan"}, + }, nil) }, - expectMock: true, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface200JSONResponse) s.True(ok) @@ -80,9 +81,9 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, }, { - name: "validation error non-alphanum interface name", - request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth-0!"}, - expectMock: false, + name: "validation error non-alphanum interface name", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth-0!"}, + setupMock: func() {}, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) s.True(ok) @@ -92,9 +93,9 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, }, { - name: "validation error empty interface name", - request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: ""}, - expectMock: false, + name: "validation error empty interface name", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: ""}, + setupMock: func() {}, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) s.True(ok) @@ -109,7 +110,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() InterfaceName: "eth0", Params: gen.GetNetworkDNSByInterfaceParams{TargetHostname: strPtr("")}, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface400JSONResponse) s.True(ok) @@ -119,10 +120,49 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, }, { - name: "job client error", - request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth0"}, - mockError: assert.AnError, - expectMock: true, + name: "job client error", + request: gen.GetNetworkDNSByInterfaceRequestObject{InterfaceName: "eth0"}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkDNS(gomock.Any(), gomock.Any(), "eth0"). + Return(nil, assert.AnError) + }, + validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { + _, ok := resp.(gen.GetNetworkDNSByInterface500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast all success", + request: gen.GetNetworkDNSByInterfaceRequestObject{ + InterfaceName: "eth0", + Params: gen.GetNetworkDNSByInterfaceParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkDNSAll(gomock.Any(), "eth0"). + Return(map[string]*dns.Config{ + "server1": { + DNSServers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + }, + }, nil) + }, + validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all error", + request: gen.GetNetworkDNSByInterfaceRequestObject{ + InterfaceName: "eth0", + Params: gen.GetNetworkDNSByInterfaceParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkDNSAll(gomock.Any(), "eth0"). + Return(nil, assert.AnError) + }, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { _, ok := resp.(gen.GetNetworkDNSByInterface500JSONResponse) s.True(ok) @@ -132,11 +172,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() for _, tt := range tests { s.Run(tt.name, func() { - if tt.expectMock { - s.mockJobClient.EXPECT(). - QueryNetworkDNS(gomock.Any(), gomock.Any(), tt.request.InterfaceName). - Return(tt.mockConfig, tt.mockError) - } + tt.setupMock() resp, err := s.handler.GetNetworkDNSByInterface(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/network/network_dns_put_by_interface_integration_test.go b/internal/api/network/network_dns_put_by_interface_integration_test.go index 3a38e90a..e4c5e661 100644 --- a/internal/api/network/network_dns_put_by_interface_integration_test.go +++ b/internal/api/network/network_dns_put_by_interface_integration_test.go @@ -60,6 +60,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TearDownTest() { func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { tests := []struct { name string + path string body string setupJobMock func() *jobmocks.MockJobClient wantCode int @@ -67,6 +68,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { }{ { name: "when valid request", + path: "/network/dns", body: `{"servers":["1.1.1.1","8.8.8.8"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) @@ -79,6 +81,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { }, { name: "when missing interface name", + path: "/network/dns", body: `{"servers":["1.1.1.1"]}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -88,6 +91,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { }, { name: "when non-alphanum interface name", + path: "/network/dns", body: `{"servers":["1.1.1.1"],"interface_name":"eth-0!"}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -97,6 +101,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { }, { name: "when invalid server IP", + path: "/network/dns", body: `{"servers":["not-an-ip"],"interface_name":"eth0"}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -106,6 +111,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { }, { name: "when invalid search domain", + path: "/network/dns", body: `{"search_domains":["not a valid hostname!"],"interface_name":"eth0"}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -113,6 +119,22 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { wantCode: http.StatusBadRequest, wantContains: []string{`"error"`, "SearchDomains", "hostname"}, }, + { + name: "when broadcast all", + path: "/network/dns?target_hostname=_all", + body: `{"servers":["1.1.1.1"],"search_domains":["foo.bar"],"interface_name":"eth0"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]error{ + "server1": nil, + }, nil) + return mock + }, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"server1"`}, + }, } for _, tc := range tests { @@ -127,7 +149,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { req := httptest.NewRequest( http.MethodPut, - "/network/dns", + tc.path, strings.NewReader(tc.body), ) req.Header.Set("Content-Type", "application/json") diff --git a/internal/api/network/network_dns_put_by_interface_public_test.go b/internal/api/network/network_dns_put_by_interface_public_test.go index c0559bc9..27d9268e 100644 --- a/internal/api/network/network_dns_put_by_interface_public_test.go +++ b/internal/api/network/network_dns_put_by_interface_public_test.go @@ -22,6 +22,7 @@ package network_test import ( "context" + "fmt" "testing" "github.com/golang/mock/gomock" @@ -61,8 +62,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { tests := []struct { name string request gen.PutNetworkDNSRequestObject - mockError error - expectMock bool + setupMock func() validateFunc func(resp gen.PutNetworkDNSResponseObject) }{ { @@ -74,7 +74,11 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { InterfaceName: interfaceName, }, }, - expectMock: true, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil) + }, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { _, ok := resp.(gen.PutNetworkDNS202Response) s.True(ok) @@ -87,7 +91,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { Servers: &servers, }, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { r, ok := resp.(gen.PutNetworkDNS400JSONResponse) s.True(ok) @@ -106,7 +110,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { }, Params: gen.PutNetworkDNSParams{TargetHostname: strPtr("")}, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { r, ok := resp.(gen.PutNetworkDNS400JSONResponse) s.True(ok) @@ -124,8 +128,75 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { InterfaceName: interfaceName, }, }, - mockError: assert.AnError, - expectMock: true, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(assert.AnError) + }, + validateFunc: func(resp gen.PutNetworkDNSResponseObject) { + _, ok := resp.(gen.PutNetworkDNS500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast all success", + request: gen.PutNetworkDNSRequestObject{ + Body: &gen.PutNetworkDNSJSONRequestBody{ + Servers: &servers, + SearchDomains: &searchDomains, + InterfaceName: interfaceName, + }, + Params: gen.PutNetworkDNSParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]error{ + "server1": nil, + "server2": nil, + }, nil) + }, + validateFunc: func(resp gen.PutNetworkDNSResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all with partial failure", + request: gen.PutNetworkDNSRequestObject{ + Body: &gen.PutNetworkDNSJSONRequestBody{ + Servers: &servers, + SearchDomains: &searchDomains, + InterfaceName: interfaceName, + }, + Params: gen.PutNetworkDNSParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(map[string]error{ + "server1": nil, + "server2": fmt.Errorf("disk full"), + }, nil) + }, + validateFunc: func(resp gen.PutNetworkDNSResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all error", + request: gen.PutNetworkDNSRequestObject{ + Body: &gen.PutNetworkDNSJSONRequestBody{ + Servers: &servers, + SearchDomains: &searchDomains, + InterfaceName: interfaceName, + }, + Params: gen.PutNetworkDNSParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + Return(nil, assert.AnError) + }, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { _, ok := resp.(gen.PutNetworkDNS500JSONResponse) s.True(ok) @@ -135,11 +206,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { for _, tt := range tests { s.Run(tt.name, func() { - if tt.expectMock { - s.mockJobClient.EXPECT(). - ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(tt.mockError) - } + tt.setupMock() resp, err := s.handler.PutNetworkDNS(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/network/network_ping_post_integration_test.go b/internal/api/network/network_ping_post_integration_test.go index 9397ab80..de6f440a 100644 --- a/internal/api/network/network_ping_post_integration_test.go +++ b/internal/api/network/network_ping_post_integration_test.go @@ -62,6 +62,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TearDownTest() { func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { tests := []struct { name string + path string body string setupJobMock func() *jobmocks.MockJobClient wantCode int @@ -69,6 +70,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { }{ { name: "when valid request", + path: "/network/ping", body: `{"address":"1.1.1.1"}`, setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) @@ -89,6 +91,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { }, { name: "when missing address", + path: "/network/ping", body: `{}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -98,6 +101,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { }, { name: "when invalid address format", + path: "/network/ping", body: `{"address":"not-an-ip"}`, setupJobMock: func() *jobmocks.MockJobClient { return jobmocks.NewMockJobClient(suite.ctrl) @@ -105,6 +109,29 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { wantCode: http.StatusBadRequest, wantContains: []string{`"error"`, "Address", "ip"}, }, + { + name: "when broadcast all", + path: "/network/ping?target_hostname=_all", + body: `{"address":"1.1.1.1"}`, + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + Return(map[string]*ping.Result{ + "server1": { + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"packets_sent":3`}, + }, } for _, tc := range tests { @@ -119,7 +146,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { req := httptest.NewRequest( http.MethodPost, - "/network/ping", + tc.path, strings.NewReader(tc.body), ) req.Header.Set("Content-Type", "application/json") diff --git a/internal/api/network/network_ping_post_public_test.go b/internal/api/network/network_ping_post_public_test.go index d6b1b9f2..32f2010f 100644 --- a/internal/api/network/network_ping_post_public_test.go +++ b/internal/api/network/network_ping_post_public_test.go @@ -59,9 +59,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { tests := []struct { name string request gen.PostNetworkPingRequestObject - mockResult *ping.Result - mockError error - expectMock bool + setupMock func() validateFunc func(resp gen.PostNetworkPingResponseObject) }{ { @@ -71,15 +69,18 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { Address: "1.1.1.1", }, }, - mockResult: &ping.Result{ - PacketsSent: 3, - PacketsReceived: 3, - PacketLoss: 0, - MinRTT: 10 * time.Millisecond, - AvgRTT: 15 * time.Millisecond, - MaxRTT: 20 * time.Millisecond, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkPing(gomock.Any(), gomock.Any(), "1.1.1.1"). + Return(&ping.Result{ + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, nil) }, - expectMock: true, validateFunc: func(resp gen.PostNetworkPingResponseObject) { r, ok := resp.(gen.PostNetworkPing200JSONResponse) s.True(ok) @@ -95,7 +96,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { Address: "not-an-ip", }, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.PostNetworkPingResponseObject) { r, ok := resp.(gen.PostNetworkPing400JSONResponse) s.True(ok) @@ -112,7 +113,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { }, Params: gen.PostNetworkPingParams{TargetHostname: strPtr("")}, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.PostNetworkPingResponseObject) { r, ok := resp.(gen.PostNetworkPing400JSONResponse) s.True(ok) @@ -128,8 +129,55 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { Address: "1.1.1.1", }, }, - mockError: assert.AnError, - expectMock: true, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkPing(gomock.Any(), gomock.Any(), "1.1.1.1"). + Return(nil, assert.AnError) + }, + validateFunc: func(resp gen.PostNetworkPingResponseObject) { + _, ok := resp.(gen.PostNetworkPing500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast all success", + request: gen.PostNetworkPingRequestObject{ + Body: &gen.PostNetworkPingJSONRequestBody{ + Address: "1.1.1.1", + }, + Params: gen.PostNetworkPingParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + Return(map[string]*ping.Result{ + "server1": { + PacketsSent: 3, + PacketsReceived: 3, + PacketLoss: 0, + MinRTT: 10 * time.Millisecond, + AvgRTT: 15 * time.Millisecond, + MaxRTT: 20 * time.Millisecond, + }, + }, nil) + }, + validateFunc: func(resp gen.PostNetworkPingResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all error", + request: gen.PostNetworkPingRequestObject{ + Body: &gen.PostNetworkPingJSONRequestBody{ + Address: "1.1.1.1", + }, + Params: gen.PostNetworkPingParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + Return(nil, assert.AnError) + }, validateFunc: func(resp gen.PostNetworkPingResponseObject) { _, ok := resp.(gen.PostNetworkPing500JSONResponse) s.True(ok) @@ -139,11 +187,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { for _, tt := range tests { s.Run(tt.name, func() { - if tt.expectMock { - s.mockJobClient.EXPECT(). - QueryNetworkPing(gomock.Any(), gomock.Any(), tt.request.Body.Address). - Return(tt.mockResult, tt.mockError) - } + tt.setupMock() resp, err := s.handler.PostNetworkPing(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/system/system_hostname_get_integration_test.go b/internal/api/system/system_hostname_get_integration_test.go index 203a0424..1cc24b8b 100644 --- a/internal/api/system/system_hostname_get_integration_test.go +++ b/internal/api/system/system_hostname_get_integration_test.go @@ -65,6 +65,7 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { setupJobMock func() *jobmocks.MockJobClient wantCode int wantBody string + wantContains []string }{ { name: "when get Ok", @@ -92,6 +93,22 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { wantCode: http.StatusInternalServerError, wantBody: `{"error":"assert.AnError general error for testing"}`, }, + { + name: "when broadcast all", + path: "/system/hostname?target_hostname=_all", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + QuerySystemHostnameAll(gomock.Any()). + Return(map[string]string{ + "server1": "host1", + "server2": "host2", + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"host1"`, `"host2"`}, + }, } for _, tc := range tests { @@ -110,7 +127,12 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { a.Echo.ServeHTTP(rec, req) suite.Equal(tc.wantCode, rec.Code) - suite.JSONEq(tc.wantBody, rec.Body.String()) + if tc.wantBody != "" { + suite.JSONEq(tc.wantBody, rec.Body.String()) + } + for _, s := range tc.wantContains { + suite.Contains(rec.Body.String(), s) + } }) } } diff --git a/internal/api/system/system_hostname_get_public_test.go b/internal/api/system/system_hostname_get_public_test.go index 6ef2ea3f..717ba993 100644 --- a/internal/api/system/system_hostname_get_public_test.go +++ b/internal/api/system/system_hostname_get_public_test.go @@ -57,16 +57,17 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { tests := []struct { name string request gen.GetSystemHostnameRequestObject - mockResult string - mockError error - expectMock bool + setupMock func() validateFunc func(resp gen.GetSystemHostnameResponseObject) }{ { - name: "success", - request: gen.GetSystemHostnameRequestObject{}, - mockResult: "my-hostname", - expectMock: true, + name: "success", + request: gen.GetSystemHostnameRequestObject{}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemHostname(gomock.Any(), gomock.Any()). + Return("my-hostname", nil) + }, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { r, ok := resp.(gen.GetSystemHostname200JSONResponse) s.True(ok) @@ -78,7 +79,7 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { request: gen.GetSystemHostnameRequestObject{ Params: gen.GetSystemHostnameParams{TargetHostname: strPtr("")}, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { r, ok := resp.(gen.GetSystemHostname400JSONResponse) s.True(ok) @@ -88,10 +89,45 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { }, }, { - name: "job client error", - request: gen.GetSystemHostnameRequestObject{}, - mockError: assert.AnError, - expectMock: true, + name: "job client error", + request: gen.GetSystemHostnameRequestObject{}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemHostname(gomock.Any(), gomock.Any()). + Return("", assert.AnError) + }, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + _, ok := resp.(gen.GetSystemHostname500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast all success", + request: gen.GetSystemHostnameRequestObject{ + Params: gen.GetSystemHostnameParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemHostnameAll(gomock.Any()). + Return(map[string]string{ + "server1": "host1", + "server2": "host2", + }, nil) + }, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all error", + request: gen.GetSystemHostnameRequestObject{ + Params: gen.GetSystemHostnameParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemHostnameAll(gomock.Any()). + Return(nil, assert.AnError) + }, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { _, ok := resp.(gen.GetSystemHostname500JSONResponse) s.True(ok) @@ -101,11 +137,7 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { for _, tt := range tests { s.Run(tt.name, func() { - if tt.expectMock { - s.mockJobClient.EXPECT(). - QuerySystemHostname(gomock.Any(), gomock.Any()). - Return(tt.mockResult, tt.mockError) - } + tt.setupMock() resp, err := s.handler.GetSystemHostname(s.ctx, tt.request) s.NoError(err) diff --git a/internal/api/system/system_status_get_integration_test.go b/internal/api/system/system_status_get_integration_test.go index c56bb079..9526aeef 100644 --- a/internal/api/system/system_status_get_integration_test.go +++ b/internal/api/system/system_status_get_integration_test.go @@ -70,6 +70,7 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { setupJobMock func() *jobmocks.MockJobClient wantCode int wantBody string + wantContains []string }{ { name: "when get Ok", @@ -149,6 +150,28 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { wantCode: http.StatusInternalServerError, wantBody: `{"error":"assert.AnError general error for testing"}`, }, + { + name: "when broadcast all", + path: "/system/status?target_hostname=_all", + setupJobMock: func() *jobmocks.MockJobClient { + mock := jobmocks.NewMockJobClient(suite.ctrl) + mock.EXPECT(). + QuerySystemStatusAll(gomock.Any()). + Return([]*job.SystemStatusResponse{ + { + Hostname: "server1", + Uptime: time.Hour, + }, + { + Hostname: "server2", + Uptime: 2 * time.Hour, + }, + }, nil) + return mock + }, + wantCode: http.StatusOK, + wantContains: []string{`"results"`, `"server1"`, `"server2"`}, + }, } for _, tc := range tests { @@ -167,7 +190,12 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { a.Echo.ServeHTTP(rec, req) suite.Equal(tc.wantCode, rec.Code) - suite.JSONEq(tc.wantBody, rec.Body.String()) + if tc.wantBody != "" { + suite.JSONEq(tc.wantBody, rec.Body.String()) + } + for _, s := range tc.wantContains { + suite.Contains(rec.Body.String(), s) + } }) } } diff --git a/internal/api/system/system_status_get_public_test.go b/internal/api/system/system_status_get_public_test.go index 265ef262..1d957ff3 100644 --- a/internal/api/system/system_status_get_public_test.go +++ b/internal/api/system/system_status_get_public_test.go @@ -59,19 +59,20 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { tests := []struct { name string request gen.GetSystemStatusRequestObject - mockResult *jobtypes.SystemStatusResponse - mockError error - expectMock bool + setupMock func() validateFunc func(resp gen.GetSystemStatusResponseObject) }{ { name: "success", request: gen.GetSystemStatusRequestObject{}, - mockResult: &jobtypes.SystemStatusResponse{ - Hostname: "test-host", - Uptime: time.Hour, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemStatus(gomock.Any(), gomock.Any()). + Return(&jobtypes.SystemStatusResponse{ + Hostname: "test-host", + Uptime: time.Hour, + }, nil) }, - expectMock: true, validateFunc: func(resp gen.GetSystemStatusResponseObject) { _, ok := resp.(gen.GetSystemStatus200JSONResponse) s.True(ok) @@ -82,7 +83,7 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { request: gen.GetSystemStatusRequestObject{ Params: gen.GetSystemStatusParams{TargetHostname: strPtr("")}, }, - expectMock: false, + setupMock: func() {}, validateFunc: func(resp gen.GetSystemStatusResponseObject) { r, ok := resp.(gen.GetSystemStatus400JSONResponse) s.True(ok) @@ -92,10 +93,45 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { }, }, { - name: "job client error", - request: gen.GetSystemStatusRequestObject{}, - mockError: assert.AnError, - expectMock: true, + name: "job client error", + request: gen.GetSystemStatusRequestObject{}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemStatus(gomock.Any(), gomock.Any()). + Return(nil, assert.AnError) + }, + validateFunc: func(resp gen.GetSystemStatusResponseObject) { + _, ok := resp.(gen.GetSystemStatus500JSONResponse) + s.True(ok) + }, + }, + { + name: "broadcast all success", + request: gen.GetSystemStatusRequestObject{ + Params: gen.GetSystemStatusParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemStatusAll(gomock.Any()). + Return([]*jobtypes.SystemStatusResponse{ + {Hostname: "server1", Uptime: time.Hour}, + {Hostname: "server2", Uptime: 2 * time.Hour}, + }, nil) + }, + validateFunc: func(resp gen.GetSystemStatusResponseObject) { + s.NotNil(resp) + }, + }, + { + name: "broadcast all error", + request: gen.GetSystemStatusRequestObject{ + Params: gen.GetSystemStatusParams{TargetHostname: strPtr("_all")}, + }, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemStatusAll(gomock.Any()). + Return(nil, assert.AnError) + }, validateFunc: func(resp gen.GetSystemStatusResponseObject) { _, ok := resp.(gen.GetSystemStatus500JSONResponse) s.True(ok) @@ -105,11 +141,7 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { for _, tt := range tests { s.Run(tt.name, func() { - if tt.expectMock { - s.mockJobClient.EXPECT(). - QuerySystemStatus(gomock.Any(), gomock.Any()). - Return(tt.mockResult, tt.mockError) - } + tt.setupMock() resp, err := s.handler.GetSystemStatus(s.ctx, tt.request) s.NoError(err) diff --git a/internal/job/client/modify_public_test.go b/internal/job/client/modify_public_test.go index 9cdc54b7..a6a92ac1 100644 --- a/internal/job/client/modify_public_test.go +++ b/internal/job/client/modify_public_test.go @@ -202,6 +202,113 @@ func (s *ModifyPublicTestSuite) TestModifyNetworkDNSAny() { } } +func (s *ModifyPublicTestSuite) TestModifyNetworkDNSAll() { + tests := []struct { + name string + timeout time.Duration + opts *publishAndCollectMockOpts + expectError bool + errorContains string + expectedCount int + expectHostErr bool + }{ + { + name: "all hosts succeed", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"updated":true}}`, + `{"status":"completed","hostname":"server2","data":{"updated":true}}`, + }, + }, + expectedCount: 2, + }, + { + name: "partial failure", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"updated":true}}`, + `{"status":"failed","hostname":"server2","error":"disk full"}`, + }, + }, + expectedCount: 2, + expectHostErr: true, + }, + { + name: "publish error", + opts: &publishAndCollectMockOpts{ + mockError: errors.New("publish error"), + errorMode: errorOnPublish, + }, + expectError: true, + errorContains: "failed to collect broadcast responses", + }, + { + name: "no workers respond", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + mockError: errors.New("unused"), + errorMode: errorOnTimeout, + }, + expectError: true, + errorContains: "no workers responded", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + timeout := tt.timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + opts := &client.Options{ + Timeout: timeout, + KVBucket: s.mockKV, + } + jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) + s.Require().NoError(err) + + setupPublishAndCollectMocks( + s.mockCtrl, + s.mockKV, + s.mockNATSClient, + "jobs.modify._all", + tt.opts, + ) + + result, err := jobsClient.ModifyNetworkDNSAll( + s.ctx, + []string{"8.8.8.8"}, + []string{"example.com"}, + "eth0", + ) + + if tt.expectError { + s.Error(err) + s.Nil(result) + if tt.errorContains != "" { + s.Contains(err.Error(), tt.errorContains) + } + } else { + s.NoError(err) + s.Len(result, tt.expectedCount) + if tt.expectHostErr { + hasErr := false + for _, hostErr := range result { + if hostErr != nil { + hasErr = true + break + } + } + s.True(hasErr) + } + } + }) + } +} + func TestModifyPublicTestSuite(t *testing.T) { suite.Run(t, new(ModifyPublicTestSuite)) } diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go index 58cb632d..003f6960 100644 --- a/internal/job/client/query_public_test.go +++ b/internal/job/client/query_public_test.go @@ -823,6 +823,358 @@ func (s *QueryPublicTestSuite) TestQuerySystemStatusAll() { } } +func (s *QueryPublicTestSuite) TestQuerySystemHostnameAll() { + tests := []struct { + name string + timeout time.Duration + opts *publishAndCollectMockOpts + expectError bool + errorContains string + expectedCount int + }{ + { + name: "multiple hosts respond", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"host1.example.com"}}`, + `{"status":"completed","hostname":"server2","data":{"hostname":"host2.example.com"}}`, + }, + }, + expectedCount: 2, + }, + { + name: "failed responses skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"host1.example.com"}}`, + `{"status":"failed","hostname":"server2","error":"unreachable"}`, + }, + }, + expectedCount: 1, + }, + { + name: "unmarshal error skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"host1.example.com"}}`, + `{"status":"completed","hostname":"server2","data":"invalid_not_object"}`, + }, + }, + expectedCount: 1, + }, + { + name: "publish error", + opts: &publishAndCollectMockOpts{ + mockError: errors.New("publish error"), + errorMode: errorOnPublish, + }, + expectError: true, + errorContains: "failed to collect broadcast responses", + }, + { + name: "no workers respond", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + mockError: errors.New("unused"), + errorMode: errorOnTimeout, + }, + expectError: true, + errorContains: "no workers responded", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + timeout := tt.timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + opts := &client.Options{ + Timeout: timeout, + KVBucket: s.mockKV, + } + jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) + s.Require().NoError(err) + + setupPublishAndCollectMocks( + s.mockCtrl, + s.mockKV, + s.mockNATSClient, + "jobs.query._all", + tt.opts, + ) + + result, err := jobsClient.QuerySystemHostnameAll(s.ctx) + + if tt.expectError { + s.Error(err) + s.Nil(result) + if tt.errorContains != "" { + s.Contains(err.Error(), tt.errorContains) + } + } else { + s.NoError(err) + s.Len(result, tt.expectedCount) + } + }) + } +} + +func (s *QueryPublicTestSuite) TestQueryNetworkDNSAll() { + tests := []struct { + name string + timeout time.Duration + opts *publishAndCollectMockOpts + expectError bool + errorContains string + expectedCount int + }{ + { + name: "multiple hosts respond", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"DNSServers":["8.8.8.8"],"SearchDomains":["example.com"]}}`, + `{"status":"completed","hostname":"server2","data":{"DNSServers":["1.1.1.1"],"SearchDomains":["local"]}}`, + }, + }, + expectedCount: 2, + }, + { + name: "failed responses skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"DNSServers":["8.8.8.8"],"SearchDomains":[]}}`, + `{"status":"failed","hostname":"server2","error":"interface not found"}`, + }, + }, + expectedCount: 1, + }, + { + name: "unmarshal error skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"DNSServers":["8.8.8.8"],"SearchDomains":[]}}`, + `{"status":"completed","hostname":"server2","data":"not_an_object"}`, + }, + }, + expectedCount: 1, + }, + { + name: "publish error", + opts: &publishAndCollectMockOpts{ + mockError: errors.New("publish error"), + errorMode: errorOnPublish, + }, + expectError: true, + errorContains: "failed to collect broadcast responses", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + timeout := tt.timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + opts := &client.Options{ + Timeout: timeout, + KVBucket: s.mockKV, + } + jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) + s.Require().NoError(err) + + setupPublishAndCollectMocks( + s.mockCtrl, + s.mockKV, + s.mockNATSClient, + "jobs.query._all", + tt.opts, + ) + + result, err := jobsClient.QueryNetworkDNSAll(s.ctx, "eth0") + + if tt.expectError { + s.Error(err) + s.Nil(result) + if tt.errorContains != "" { + s.Contains(err.Error(), tt.errorContains) + } + } else { + s.NoError(err) + s.Len(result, tt.expectedCount) + } + }) + } +} + +func (s *QueryPublicTestSuite) TestQueryNetworkPingAll() { + tests := []struct { + name string + timeout time.Duration + opts *publishAndCollectMockOpts + expectError bool + errorContains string + expectedCount int + }{ + { + name: "multiple hosts respond", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"PacketsSent":4,"PacketsReceived":4,"PacketLoss":0.0}}`, + `{"status":"completed","hostname":"server2","data":{"PacketsSent":4,"PacketsReceived":3,"PacketLoss":25.0}}`, + }, + }, + expectedCount: 2, + }, + { + name: "failed responses skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"PacketsSent":4,"PacketsReceived":4,"PacketLoss":0.0}}`, + `{"status":"failed","hostname":"server2","error":"host unreachable"}`, + }, + }, + expectedCount: 1, + }, + { + name: "unmarshal error skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"PacketsSent":4,"PacketsReceived":4,"PacketLoss":0.0}}`, + `{"status":"completed","hostname":"server2","data":"not_an_object"}`, + }, + }, + expectedCount: 1, + }, + { + name: "publish error", + opts: &publishAndCollectMockOpts{ + mockError: errors.New("publish error"), + errorMode: errorOnPublish, + }, + expectError: true, + errorContains: "failed to collect broadcast responses", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + timeout := tt.timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + opts := &client.Options{ + Timeout: timeout, + KVBucket: s.mockKV, + } + jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) + s.Require().NoError(err) + + setupPublishAndCollectMocks( + s.mockCtrl, + s.mockKV, + s.mockNATSClient, + "jobs.query._all", + tt.opts, + ) + + result, err := jobsClient.QueryNetworkPingAll(s.ctx, "1.1.1.1") + + if tt.expectError { + s.Error(err) + s.Nil(result) + if tt.errorContains != "" { + s.Contains(err.Error(), tt.errorContains) + } + } else { + s.NoError(err) + s.Len(result, tt.expectedCount) + } + }) + } +} + +func (s *QueryPublicTestSuite) TestListWorkers() { + tests := []struct { + name string + timeout time.Duration + opts *publishAndCollectMockOpts + expectError bool + errorContains string + expectedCount int + }{ + { + name: "multiple workers discovered", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"worker1"}}`, + `{"status":"completed","hostname":"server2","data":{"hostname":"worker2"}}`, + }, + }, + expectedCount: 2, + }, + { + name: "broadcast error", + opts: &publishAndCollectMockOpts{ + mockError: errors.New("publish error"), + errorMode: errorOnPublish, + }, + expectError: true, + errorContains: "failed to discover workers", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + timeout := tt.timeout + if timeout == 0 { + timeout = 30 * time.Second + } + + opts := &client.Options{ + Timeout: timeout, + KVBucket: s.mockKV, + } + jobsClient, err := client.New(slog.Default(), s.mockNATSClient, opts) + s.Require().NoError(err) + + setupPublishAndCollectMocks( + s.mockCtrl, + s.mockKV, + s.mockNATSClient, + "jobs.query._all", + tt.opts, + ) + + result, err := jobsClient.ListWorkers(s.ctx) + + if tt.expectError { + s.Error(err) + s.Nil(result) + if tt.errorContains != "" { + s.Contains(err.Error(), tt.errorContains) + } + } else { + s.NoError(err) + s.Len(result, tt.expectedCount) + } + }) + } +} + func TestQueryPublicTestSuite(t *testing.T) { suite.Run(t, new(QueryPublicTestSuite)) }