From 9ff1b6411f4b13d207aacbb47ba4c5b61dc6c2eb 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 22:09:30 -0800 Subject: [PATCH 1/2] feat: Uniform collection responses with host attribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All API endpoints now return a consistent collection shape: {"results": [{"hostname": "...", ...fields...}]}. Single-host responses wrap 1 item; broadcast (_all) returns N items. This eliminates the inconsistency between single-host and broadcast response formats and ensures every response includes worker hostname attribution. - Update OpenAPI specs with collection wrapper schemas - Thread worker hostname through job client return values - Fix WriteJobResponse to serialize hostname in response body - Update all handlers to return collection types - Update CLI to parse collections with HOSTNAME column - Update CLI docs with accurate output examples - Add backlog tasks for 100% coverage and label-based routing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../2026-02-17-coverage-100-percent.md | 89 +++++++ .../2026-02-17-label-based-worker-routing.md | 231 ++++++++++++++++++ cmd/client_network_dns_get.go | 31 ++- cmd/client_network_dns_update.go | 42 +++- cmd/client_network_ping.go | 22 +- cmd/client_system_hostname_get.go | 13 +- cmd/client_system_status_get.go | 81 +++--- .../usage/cli/client/network/dns/get.md | 20 +- .../usage/cli/client/network/dns/update.md | 30 ++- .../usage/cli/client/network/ping/ping.md | 26 +- .../usage/cli/client/system/hostname.md | 18 +- .../sidebar/usage/cli/client/system/status.md | 30 ++- internal/api/network/gen/api.yaml | 62 ++++- internal/api/network/gen/network.gen.go | 50 +++- .../network/network_dns_get_by_interface.go | 38 ++- ...k_dns_get_by_interface_integration_test.go | 12 +- ...etwork_dns_get_by_interface_public_test.go | 10 +- .../network/network_dns_put_by_interface.go | 56 ++--- ...k_dns_put_by_interface_integration_test.go | 5 +- ...etwork_dns_put_by_interface_public_test.go | 9 +- internal/api/network/network_ping_post.go | 42 ++-- .../network_ping_post_integration_test.go | 4 +- .../network/network_ping_post_public_test.go | 12 +- internal/api/system/gen/api.yaml | 24 +- internal/api/system/gen/system.gen.go | 14 +- internal/api/system/system_hostname_get.go | 31 +-- .../system_hostname_get_integration_test.go | 6 +- .../system/system_hostname_get_public_test.go | 7 +- internal/api/system/system_status_get.go | 24 +- .../system_status_get_integration_test.go | 48 ++-- internal/client/gen/api.yaml | 82 ++++++- internal/client/gen/client.gen.go | 71 +++++- internal/job/client/modify.go | 10 +- internal/job/client/modify_public_test.go | 9 +- internal/job/client/query.go | 32 +-- internal/job/client/query_public_test.go | 10 +- internal/job/client/types.go | 12 +- internal/job/client/worker.go | 1 + internal/job/mocks/job_client.gen.go | 42 ++-- 39 files changed, 1027 insertions(+), 329 deletions(-) create mode 100644 .tasks/backlog/2026-02-17-coverage-100-percent.md create mode 100644 .tasks/backlog/2026-02-17-label-based-worker-routing.md diff --git a/.tasks/backlog/2026-02-17-coverage-100-percent.md b/.tasks/backlog/2026-02-17-coverage-100-percent.md new file mode 100644 index 00000000..46712d13 --- /dev/null +++ b/.tasks/backlog/2026-02-17-coverage-100-percent.md @@ -0,0 +1,89 @@ +--- +title: Achieve 100% test coverage on non-generated packages +status: backlog +created: 2026-02-17 +updated: 2026-02-17 +--- + +## Objective + +Two non-generated packages are below 100% statement coverage. Add tests for +the uncovered branches to reach full coverage. + +## Packages and Gaps + +### 1. `internal/api/system` — 98.7% (target: 100%) + +**Function:** `GetSystemHostname` in `system_hostname_get.go` — 94.1% + +**Uncovered branch (lines 62-65):** + +```go +displayHostname := result +if displayHostname == "" { + displayHostname = workerHostname +} +``` + +When `QuerySystemHostname` returns an empty string for the display hostname, +the handler falls back to `workerHostname`. No test currently exercises this +path. + +**Test to add** in `system_hostname_get_public_test.go`: + +- Mock `QuerySystemHostname` to return `("", "worker1", nil)` — empty result + string with a valid worker hostname. +- Assert response contains `{"results":[{"hostname":"worker1"}]}`. + +### 2. `internal/job/client` — 99.6% (target: 100%) + +**Function:** `publishAndCollect` in `client.go` — 95.7% + +**Uncovered branch A (lines 261-266):** Unmarshal error in broadcast response. + +```go +if err := json.Unmarshal(entry.Value(), &response); err != nil { + c.logger.Warn("failed to unmarshal broadcast response", ...) + continue +} +``` + +When a KV watcher entry contains invalid JSON, the response is skipped with a +warning log. No test exercises this. + +**Test to add:** Write invalid JSON to the response KV key for a broadcast +job, then write a valid response. Assert only the valid response is collected +and the invalid one is silently skipped. + +**Uncovered branch B (lines 270-272):** Empty hostname fallback. + +```go +hostname := response.Hostname +if hostname == "" { + hostname = "unknown" +} +``` + +When a worker response has an empty `Hostname` field, it is keyed as +`"unknown"` in the results map. No test exercises this. + +**Test to add:** Write a valid response to KV with an empty `Hostname` field. +Assert the collected map has a key `"unknown"`. + +## Verification + +```bash +go test -coverprofile=/tmp/cov.out ./internal/api/system/ +go tool cover -func=/tmp/cov.out | grep -v 100.0% +# Should show only total line + +go test -coverprofile=/tmp/cov.out ./internal/job/client/ +go tool cover -func=/tmp/cov.out | grep -v 100.0% +# Should show only total line +``` + +## Notes + +- Generated packages (`gen/`, `mocks/`) and `cmd/` are excluded from this + goal — only hand-written business logic packages are targeted. +- The `internal/api/network` package is already at 100%. diff --git a/.tasks/backlog/2026-02-17-label-based-worker-routing.md b/.tasks/backlog/2026-02-17-label-based-worker-routing.md new file mode 100644 index 00000000..7e32ac3f --- /dev/null +++ b/.tasks/backlog/2026-02-17-label-based-worker-routing.md @@ -0,0 +1,231 @@ +--- +title: Label-based worker routing with NATS subject wildcards +status: backlog +created: 2026-02-17 +updated: 2026-02-17 +--- + +## Objective + +Extend worker targeting beyond `_any`, `_all`, and exact hostname. Admins need +to target groups of servers (e.g., all web servers, all prod machines, all +servers in rack-3). The solution should leverage NATS native subject wildcards +for zero-overhead routing. + +## Problem + +Today the `--target` flag accepts only: + +- `_any` — load-balanced to one random worker +- `_all` — broadcast to every worker +- `server1` — direct to a specific hostname + +There is no way to say "all web servers" or "every prod machine in us-east-1." + +## Proposed Architecture: Labels as Subject Segments + +### Core idea + +Workers publish their identity through the subjects they subscribe to. Instead +of a flat `jobs.{type}.{hostname}`, add label segments that NATS wildcards can +match against. + +### Subject format + +``` +jobs.{type}.host.{hostname} — direct to specific host +jobs.{type}.label.{key}.{value} — all hosts with that label +jobs.{type}._any — any worker (load-balanced) +jobs.{type}._all — broadcast all workers +``` + +Examples: + +``` +jobs.query.host.web-prod-01 +jobs.query.label.role.web +jobs.query.label.env.prod +jobs.query.label.rack.us-east-1a +jobs.modify.label.role.db +``` + +### Worker config + +```yaml +job: + worker: + hostname: web-prod-01 # optional, auto-detected if empty + labels: # NEW + role: web + env: prod + rack: us-east-1a +``` + +### Worker subscription behavior + +On startup, a worker with the above config subscribes to: + +``` +# Existing patterns +jobs.*.host.web-prod-01 — direct messages +jobs.*._any — load-balanced (queue group) +jobs.*._all — broadcasts + +# NEW: one subscription per label +jobs.*.label.role.web — all "role=web" jobs +jobs.*.label.env.prod — all "env=prod" jobs +jobs.*.label.rack.us-east-1a — all "rack=us-east-1a" jobs +``` + +Label subscriptions use **no queue group** so every matching worker gets the +message (broadcast semantics within the label group). If the admin wants +load-balanced label routing (send to one web server, not all), they could use +a queue group per label — but that's a future enhancement. + +### Client targeting + +The `--target` flag (and `target_hostname` query param) expands: + +| Target value | Resolves to subject | Semantics | +| ----------------------- | --------------------------------- | -------------------| +| `_any` | `jobs.{type}._any` | Load-balanced | +| `_all` | `jobs.{type}._all` | Broadcast all | +| `web-prod-01` | `jobs.{type}.host.web-prod-01` | Direct to host | +| `role:web` | `jobs.{type}.label.role.web` | Broadcast to label | +| `env:prod` | `jobs.{type}.label.env.prod` | Broadcast to label | + +The `key:value` syntax is unambiguous — hostnames cannot contain `:`. + +### Why labels over hierarchical hostnames + +Hierarchical hostnames (`prod.web.server1`) force a single taxonomy. A server +can only be in one hierarchy. Labels are multi-dimensional — a server can be +`role:web` AND `env:prod` AND `rack:us-east-1a` simultaneously. The admin +can target any dimension without restructuring naming conventions. + +### Why not a registration/discovery service + +NATS subject routing IS the discovery mechanism. Workers self-register by +subscribing to their label subjects. No external registry, no heartbeats, no +consistency problem. If a worker is running, its subscriptions are active. If +it dies, NATS removes the subscriptions. This is the simplest architecture +that could possibly work. + +## Implementation Plan + +### 1. Config changes + +**File:** `internal/config/types.go` + +```go +type JobWorker struct { + // ... existing fields ... + Labels map[string]string `mapstructure:"labels"` // NEW +} +``` + +### 2. Subject routing + +**File:** `internal/job/subjects.go` + +Add: + +```go +func BuildLabelSubject(key, value string) string { + return fmt.Sprintf("jobs.*.label.%s.%s", key, value) +} + +func BuildHostSubject(hostname string) string { + return fmt.Sprintf("jobs.*.host.%s", hostname) +} + +func ParseTarget(target string) (subjectType, key, value string) { + // "_any" → ("_any", "", "") + // "_all" → ("_all", "", "") + // "role:web" → ("label", "role", "web") + // "server1" → ("host", "server1", "") +} +``` + +**Validation:** Label keys and values must be `[a-zA-Z0-9_-]+` (NATS subject +token safe). Reject dots, spaces, wildcards. + +### 3. Worker subscriptions + +**File:** `internal/job/worker/consumer.go` + +Extend consumer creation to loop over `w.appConfig.Job.Worker.Labels` and +create a consumer + goroutine for each `label.{key}.{value}` pattern. + +### 4. Stream subjects + +**File:** Config / stream setup + +Update JetStream stream subjects to include the new patterns: + +``` +jobs.query.> +jobs.modify.> +``` + +The `>` wildcard already covers any depth, so this should already work if the +stream is configured with `jobs.>` or similar. Verify the current +`StreamSubjects` config value. + +### 5. Client-side targeting + +**File:** `internal/job/client/query.go`, `modify.go` + +Update `BuildQuerySubject` / `BuildModifySubject` calls to parse the target +and build the correct subject. The `publishAndCollect` method already handles +multi-response collection, so label targeting works like `_all`. + +**File:** `cmd/` CLI files + +Update `--target` flag help text and validation. + +### 6. API changes + +**File:** OpenAPI specs + +Update `target_hostname` parameter description and validation to accept +`key:value` label syntax. Consider renaming to `target` in a future version. + +### 7. Worker discovery + +**File:** `internal/job/client/query.go` — `ListWorkers` + +Extend worker discovery to optionally filter by label. When listing workers, +each worker's response should include its labels so the admin can see the +topology. + +## Migration + +- **Backwards compatible:** Existing configs with no `labels` key work + unchanged. The bare hostname targeting becomes `host.{hostname}` internally + but the `--target server1` syntax stays the same. +- **Stream subjects:** If currently `jobs.query.*` (single token wildcard), + must widen to `jobs.query.>` (multi-token). This is a one-time migration on + stream recreation. + +## Future Enhancements (out of scope) + +- **Multi-label targeting:** `role:web,env:prod` (AND semantics — requires + client-side intersection of results) +- **Load-balanced label routing:** `role:web:any` to pick one web server + (queue group per label) +- **Label wildcards:** `env:*` to target all environments +- **Admin CLI for label management:** `osapi admin workers list --label + role:web` +- **Dynamic label registration:** Workers can update labels at runtime via + NATS + +## Notes + +- NATS subject tokens cannot contain `.` so label values like `us-east-1` are + fine but `us.east.1` would break subject parsing. Use hyphens or underscores. +- Label-based subscriptions scale linearly with the number of unique labels + per worker. A worker with 5 labels creates 5 additional consumers. This is + well within NATS limits. +- Consider documenting recommended label taxonomies: `role`, `env`, `region`, + `rack`, `team`. diff --git a/cmd/client_network_dns_get.go b/cmd/client_network_dns_get.go index 7e3ee3fc..0d40d521 100644 --- a/cmd/client_network_dns_get.go +++ b/cmd/client_network_dns_get.go @@ -57,19 +57,28 @@ var clientNetworkDNSGetCmd = &cobra.Command{ logFatal("failed response", fmt.Errorf("get dns response was nil")) } - var searchDomainsList, serversList []string - if resp.JSON200.SearchDomains != nil { - searchDomainsList = *resp.JSON200.SearchDomains + rows := make([][]string, 0, len(resp.JSON200.Results)) + for _, cfg := range resp.JSON200.Results { + var serversList, searchDomainsList []string + if cfg.Servers != nil { + serversList = *cfg.Servers + } + if cfg.SearchDomains != nil { + searchDomainsList = *cfg.SearchDomains + } + rows = append(rows, []string{ + cfg.Hostname, + formatList(serversList), + formatList(searchDomainsList), + }) } - if resp.JSON200.Servers != nil { - serversList = *resp.JSON200.Servers + sections := []section{ + { + Headers: []string{"HOSTNAME", "SERVERS", "SEARCH DOMAINS"}, + Rows: rows, + }, } - - dnsData := map[string]interface{}{ - "Search Domains": formatList(searchDomainsList), - "Servers": formatList(serversList), - } - printStyledMap(dnsData) + printStyledTable(sections) case http.StatusUnauthorized: handleAuthError(resp.JSON401, resp.StatusCode(), logger) diff --git a/cmd/client_network_dns_update.go b/cmd/client_network_dns_update.go index 9fbd898b..0bc49fae 100644 --- a/cmd/client_network_dns_update.go +++ b/cmd/client_network_dns_update.go @@ -67,14 +67,40 @@ var clientNetworkDNSUpdateCmd = &cobra.Command{ switch resp.StatusCode() { case http.StatusAccepted: - logger.Info( - "network dns put", - slog.String("search_domains", strings.Join(searchDomains, ",")), - slog.String("servers", strings.Join(servers, ",")), - slog.Int("code", resp.StatusCode()), - slog.String("response", string(resp.Body)), - slog.String("status", "ok"), - ) + if jsonOutput { + fmt.Println(string(resp.Body)) + return + } + + if resp.JSON202 != nil && len(resp.JSON202.Results) > 0 { + rows := make([][]string, 0, len(resp.JSON202.Results)) + for _, r := range resp.JSON202.Results { + errStr := "" + if r.Error != nil { + errStr = *r.Error + } + rows = append(rows, []string{ + r.Hostname, + string(r.Status), + errStr, + }) + } + sections := []section{ + { + Headers: []string{"HOSTNAME", "STATUS", "ERROR"}, + Rows: rows, + }, + } + printStyledTable(sections) + } else { + logger.Info( + "network dns put", + slog.String("search_domains", strings.Join(searchDomains, ",")), + slog.String("servers", strings.Join(servers, ",")), + slog.Int("code", resp.StatusCode()), + slog.String("status", "ok"), + ) + } case http.StatusUnauthorized: handleAuthError(resp.JSON401, resp.StatusCode(), logger) diff --git a/cmd/client_network_ping.go b/cmd/client_network_ping.go index ddb566ba..228a01d3 100644 --- a/cmd/client_network_ping.go +++ b/cmd/client_network_ping.go @@ -57,20 +57,24 @@ var clientNetworkPingCmd = &cobra.Command{ logFatal("failed response", fmt.Errorf("post network ping was nil")) } - respRows := make([][]string, 0, 1) - respRows = append(respRows, []string{ - safeString(resp.JSON200.AvgRtt), - safeString(resp.JSON200.MaxRtt), - safeString(resp.JSON200.MinRtt), - float64ToSafeString(resp.JSON200.PacketLoss), - intToSafeString(resp.JSON200.PacketsReceived), - intToSafeString(resp.JSON200.PacketsSent), - }) + respRows := make([][]string, 0, len(resp.JSON200.Results)) + for _, r := range resp.JSON200.Results { + respRows = append(respRows, []string{ + r.Hostname, + safeString(r.AvgRtt), + safeString(r.MaxRtt), + safeString(r.MinRtt), + float64ToSafeString(r.PacketLoss), + intToSafeString(r.PacketsReceived), + intToSafeString(r.PacketsSent), + }) + } sections := []section{ { Title: "Ping Response", Headers: []string{ + "HOSTNAME", "AVG RTT", "MAX RTT", "MIN RTT", diff --git a/cmd/client_system_hostname_get.go b/cmd/client_system_hostname_get.go index c314cea3..5953644b 100644 --- a/cmd/client_system_hostname_get.go +++ b/cmd/client_system_hostname_get.go @@ -55,10 +55,17 @@ var clientSystemHostnameGetCmd = &cobra.Command{ logFatal("failed response", fmt.Errorf("system data response was nil")) } - hostnameData := map[string]interface{}{ - "Hostname": resp.JSON200.Hostname, + rows := make([][]string, 0, len(resp.JSON200.Results)) + for _, h := range resp.JSON200.Results { + rows = append(rows, []string{h.Hostname}) } - printStyledMap(hostnameData) + sections := []section{ + { + Headers: []string{"HOSTNAME"}, + Rows: rows, + }, + } + printStyledTable(sections) case http.StatusUnauthorized: handleAuthError(resp.JSON401, resp.StatusCode(), logger) diff --git a/cmd/client_system_status_get.go b/cmd/client_system_status_get.go index dda39c06..ea92d9e7 100644 --- a/cmd/client_system_status_get.go +++ b/cmd/client_system_status_get.go @@ -21,7 +21,6 @@ package cmd import ( - "encoding/json" "fmt" "net/http" @@ -53,16 +52,11 @@ var clientSystemStatusGetCmd = &cobra.Command{ return } - if host == "_all" { - displayMultiSystemStatus(resp.Body) - return - } - if resp.JSON200 == nil { logFatal("failed response", fmt.Errorf("system data response was nil")) } - displaySingleSystemStatus(resp.JSON200) + displaySystemStatusCollection(host, resp.JSON200) case http.StatusUnauthorized: handleAuthError(resp.JSON401, resp.StatusCode(), logger) @@ -74,11 +68,46 @@ var clientSystemStatusGetCmd = &cobra.Command{ }, } -// displaySingleSystemStatus renders a single system status response. -func displaySingleSystemStatus( +// displaySystemStatusCollection renders system status results. +// For a single non-broadcast result, shows detailed output; otherwise shows a summary table. +func displaySystemStatusCollection( + target string, + data *gen.SystemStatusCollectionResponse, +) { + if len(data.Results) == 1 && target != "_all" { + displaySystemStatusDetail(&data.Results[0]) + return + } + + rows := make([][]string, 0, len(data.Results)) + for _, s := range data.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) +} + +// displaySystemStatusDetail renders a single system status response with full details. +func displaySystemStatusDetail( data *gen.SystemStatusResponse, ) { systemData := map[string]interface{}{ + "Hostname": data.Hostname, "Load Average (1m, 5m, 15m)": fmt.Sprintf( "%.2f, %.2f, %.2f", data.LoadAverage.N1min, @@ -122,40 +151,6 @@ func displaySingleSystemStatus( 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/docs/docs/sidebar/usage/cli/client/network/dns/get.md b/docs/docs/sidebar/usage/cli/client/network/dns/get.md index 6f5dafb4..01846c17 100644 --- a/docs/docs/sidebar/usage/cli/client/network/dns/get.md +++ b/docs/docs/sidebar/usage/cli/client/network/dns/get.md @@ -5,8 +5,24 @@ Get the systems DNS config: ```bash $ osapi client network dns get --interface-name eth0 - Search Domains: . - Servers: 192.168.0.247, 2607:f428:ffff:ffff::1, 2607:f428:ffff:ffff::2 + ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ SERVERS ┃ SEARCH DOMAINS ┃ + ┣━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━┫ + ┃ server1 ┃ 192.168.0.247, 2607:f428::1, 2607:f42::2 ┃ example.com ┃ + ┗━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━┛ +``` + +When targeting all hosts: + +```bash +$ osapi client network dns get --interface-name eth0 --target _all + + ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ SERVERS ┃ SEARCH DOMAINS ┃ + ┣━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━┫ + ┃ server1 ┃ 192.168.0.247, 2607:f428::1 ┃ example.com ┃ + ┃ server2 ┃ 8.8.8.8, 1.1.1.1 ┃ local ┃ + ┗━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━┛ ``` ## Flags diff --git a/docs/docs/sidebar/usage/cli/client/network/dns/update.md b/docs/docs/sidebar/usage/cli/client/network/dns/update.md index 8c44d279..45123257 100644 --- a/docs/docs/sidebar/usage/cli/client/network/dns/update.md +++ b/docs/docs/sidebar/usage/cli/client/network/dns/update.md @@ -3,8 +3,34 @@ Update the systems DNS config: ```bash -$ osapi client network dns update --search-domains "foo,bar,baz" --servers "1.1.1.1,2.2.2.2" --interface-name eth1 -10:56AM INF network dns put search_domains=foo,bar,baz servers=1.1.1.1,2.2.2.2 response="" status=ok +$ osapi client network dns update \ + --servers "1.1.1.1,2.2.2.2" \ + --search-domains "foo.bar,baz.qux" \ + --interface-name eth0 + + ┏━━━━━━━━━━┳━━━━━━━━┳━━━━━━━┓ + ┃ HOSTNAME ┃ STATUS ┃ ERROR ┃ + ┣━━━━━━━━━━╋━━━━━━━━╋━━━━━━━┫ + ┃ server1 ┃ ok ┃ ┃ + ┗━━━━━━━━━━┻━━━━━━━━┻━━━━━━━┛ +``` + +When targeting all hosts, a confirmation prompt is shown first: + +```bash +$ osapi client network dns update \ + --servers "1.1.1.1,2.2.2.2" \ + --search-domains "foo.bar" \ + --interface-name eth0 \ + --target _all +This will modify DNS on ALL hosts. Continue? [y/N] y + + ┏━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ STATUS ┃ ERROR ┃ + ┣━━━━━━━━━━╋━━━━━━━━╋━━━━━━━━━━━┫ + ┃ server1 ┃ ok ┃ ┃ + ┃ server2 ┃ failed ┃ disk full ┃ + ┗━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━┛ ``` ## Flags diff --git a/docs/docs/sidebar/usage/cli/client/network/ping/ping.md b/docs/docs/sidebar/usage/cli/client/network/ping/ping.md index 130ecd46..94ebd716 100644 --- a/docs/docs/sidebar/usage/cli/client/network/ping/ping.md +++ b/docs/docs/sidebar/usage/cli/client/network/ping/ping.md @@ -3,16 +3,30 @@ Ping the desired address: ```bash -$ osapi client network ping -a 8.8.8.8 +$ osapi client network ping --address 8.8.8.8 + Ping Response: + + ┏━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ AVG RTT ┃ MAX RTT ┃ MIN RTT ┃ PACKET LOSS ┃ PACKETS RECEIVED ┃ PACKETS SENT ┃ + ┣━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━┫ + ┃ server1 ┃ 19.707031ms ┃ 25.066977ms ┃ 13.007048ms ┃ 0.000000 ┃ 3 ┃ 3 ┃ + ┗━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┛ +``` + +When targeting all hosts: + +```bash +$ osapi client network ping --address 8.8.8.8 --target _all Ping Response: - ┏━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ AVG RTT ┃ MAX RTT ┃ MIN RTT ┃ PACKET LOSS ┃ PACKETS RECEIVED ┃ PACKETS SENT ┃ - ┣━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━┫ - ┃ 19.707031ms ┃ 25.066977ms ┃ 13.007048ms ┃ 0.000000 ┃ 3 ┃ 3 ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ AVG RTT ┃ MAX RTT ┃ MIN RTT ┃ PACKET LOSS ┃ PACKETS RECEIVED ┃ PACKETS SENT ┃ + ┣━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━╋━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━┫ + ┃ server1 ┃ 19.707031ms ┃ 25.066977ms ┃ 13.007048ms ┃ 0.000000 ┃ 3 ┃ 3 ┃ + ┃ server2 ┃ 22.345678ms ┃ 28.123456ms ┃ 18.234567ms ┃ 0.000000 ┃ 3 ┃ 3 ┃ + ┗━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┛ ``` ## Flags diff --git a/docs/docs/sidebar/usage/cli/client/system/hostname.md b/docs/docs/sidebar/usage/cli/client/system/hostname.md index 47420280..2d5ff861 100644 --- a/docs/docs/sidebar/usage/cli/client/system/hostname.md +++ b/docs/docs/sidebar/usage/cli/client/system/hostname.md @@ -5,6 +5,22 @@ Get the system's hostname: ```bash $ osapi client system hostname + ┏━━━━━━━━━━┓ + ┃ HOSTNAME ┃ + ┣━━━━━━━━━━┫ + ┃ server1 ┃ + ┗━━━━━━━━━━┛ +``` + +When targeting all hosts: + +```bash +$ osapi client system hostname --target _all - Hostname: nerd + ┏━━━━━━━━━━┓ + ┃ HOSTNAME ┃ + ┣━━━━━━━━━━┫ + ┃ server1 ┃ + ┃ server2 ┃ + ┗━━━━━━━━━━┛ ``` diff --git a/docs/docs/sidebar/usage/cli/client/system/status.md b/docs/docs/sidebar/usage/cli/client/system/status.md index 539e59e7..085b0d01 100644 --- a/docs/docs/sidebar/usage/cli/client/system/status.md +++ b/docs/docs/sidebar/usage/cli/client/system/status.md @@ -5,19 +5,31 @@ Get the system status: ```bash $ osapi client system status - + Hostname: server1 Load Average (1m, 5m, 15m): 1.83, 1.96, 2.02 - Memory: 19 GB used / 31 GB total / 0 GB free + Memory: 19 GB used / 31 GB total / 10 GB free OS: Ubuntu 24.04 - Disks: - ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ - ┃ DISK NAME ┃ TOTAL ┃ USED ┃ FREE ┃ - ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ - ┃ / ┃ 97 GB ┃ 56 GB ┃ 36 GB ┃ - ┃ /boot ┃ 1 GB ┃ 0 GB ┃ 1 GB ┃ - ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + ┏━━━━━━━━━━━┳━━━━━━━┳━━━━━━━┳━━━━━━━┓ + ┃ DISK NAME ┃ TOTAL ┃ USED ┃ FREE ┃ + ┣━━━━━━━━━━━╋━━━━━━━╋━━━━━━━╋━━━━━━━┫ + ┃ / ┃ 97 GB ┃ 56 GB ┃ 36 GB ┃ + ┃ /boot ┃ 1 GB ┃ 0 GB ┃ 1 GB ┃ + ┗━━━━━━━━━━━┻━━━━━━━┻━━━━━━━┻━━━━━━━┛ +``` + +When targeting all hosts, a summary table is shown: + +```bash +$ osapi client system status --target _all + + ┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓ + ┃ HOSTNAME ┃ UPTIME ┃ LOAD (1m) ┃ MEMORY USED ┃ + ┣━━━━━━━━━━╋━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╋━━━━━━━━━━━╋━━━━━━━━━━━━━━━┫ + ┃ server1 ┃ 64 days, 11 hours, 20 minutes ┃ 1.83 ┃ 19 GB / 31 GB ┃ + ┃ server2 ┃ 12 days, 3 hours, 45 minutes ┃ 0.45 ┃ 8 GB / 16 GB ┃ + ┗━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━━━━━━━━┛ ``` diff --git a/internal/api/network/gen/api.yaml b/internal/api/network/gen/api.yaml index 7c4322ce..40dd899a 100644 --- a/internal/api/network/gen/api.yaml +++ b/internal/api/network/gen/api.yaml @@ -72,7 +72,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PingResponse' + $ref: '#/components/schemas/PingCollectionResponse' '400': description: Invalid request payload. content: @@ -134,7 +134,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DNSConfigResponse' + $ref: '#/components/schemas/DNSConfigCollectionResponse' '400': description: Invalid interface name provided. content: @@ -188,6 +188,10 @@ paths: responses: '202': description: DNS servers update successfully accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/DNSUpdateCollectionResponse' '400': description: Invalid input. content: @@ -254,6 +258,9 @@ components: PingResponse: type: object properties: + hostname: + type: string + description: The hostname of the worker that executed the ping. packets_sent: type: integer description: Number of packets sent. @@ -279,10 +286,25 @@ components: type: string description: Maximum round-trip time as a string in Go's time.Duration format. example: "24.309240ms" + required: + - hostname + + PingCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/PingResponse' + required: + - results DNSConfigResponse: type: object properties: + hostname: + type: string + description: The hostname of the worker that served this DNS config. servers: type: array description: List of configured DNS servers. @@ -294,6 +316,42 @@ components: description: List of search domains. items: type: string + required: + - hostname + + DNSConfigCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/DNSConfigResponse' + required: + - results + + DNSUpdateResultItem: + type: object + properties: + hostname: + type: string + status: + type: string + enum: [ok, failed] + error: + type: string + required: + - hostname + - status + + DNSUpdateCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/DNSUpdateResultItem' + required: + - results DNSConfigUpdateRequest: type: object diff --git a/internal/api/network/gen/network.gen.go b/internal/api/network/gen/network.gen.go index a15c55e1..46c2c715 100644 --- a/internal/api/network/gen/network.gen.go +++ b/internal/api/network/gen/network.gen.go @@ -19,8 +19,22 @@ const ( BearerAuthScopes = "BearerAuth.Scopes" ) +// Defines values for DNSUpdateResultItemStatus. +const ( + Failed DNSUpdateResultItemStatus = "failed" + Ok DNSUpdateResultItemStatus = "ok" +) + +// DNSConfigCollectionResponse defines model for DNSConfigCollectionResponse. +type DNSConfigCollectionResponse struct { + Results []DNSConfigResponse `json:"results"` +} + // DNSConfigResponse defines model for DNSConfigResponse. type DNSConfigResponse struct { + // Hostname The hostname of the worker that served this DNS config. + Hostname string `json:"hostname"` + // SearchDomains List of search domains. SearchDomains *[]string `json:"search_domains,omitempty"` @@ -40,14 +54,37 @@ type DNSConfigUpdateRequest struct { Servers *[]string `json:"servers,omitempty" validate:"required_without=SearchDomains,omitempty,dive,ip,min=1"` } +// DNSUpdateCollectionResponse defines model for DNSUpdateCollectionResponse. +type DNSUpdateCollectionResponse struct { + Results []DNSUpdateResultItem `json:"results"` +} + +// DNSUpdateResultItem defines model for DNSUpdateResultItem. +type DNSUpdateResultItem struct { + Error *string `json:"error,omitempty"` + Hostname string `json:"hostname"` + Status DNSUpdateResultItemStatus `json:"status"` +} + +// DNSUpdateResultItemStatus defines model for DNSUpdateResultItem.Status. +type DNSUpdateResultItemStatus string + // ErrorResponse defines model for ErrorResponse. type ErrorResponse = externalRef0.ErrorResponse +// PingCollectionResponse defines model for PingCollectionResponse. +type PingCollectionResponse struct { + Results []PingResponse `json:"results"` +} + // PingResponse defines model for PingResponse. type PingResponse struct { // AvgRtt Average round-trip time as a string in Go's time.Duration format. AvgRtt *string `json:"avg_rtt,omitempty"` + // Hostname The hostname of the worker that executed the ping. + Hostname string `json:"hostname"` + // MaxRtt Maximum round-trip time as a string in Go's time.Duration format. MaxRtt *string `json:"max_rtt,omitempty"` @@ -222,12 +259,13 @@ type PutNetworkDNSResponseObject interface { VisitPutNetworkDNSResponse(w http.ResponseWriter) error } -type PutNetworkDNS202Response struct { -} +type PutNetworkDNS202JSONResponse DNSUpdateCollectionResponse -func (response PutNetworkDNS202Response) VisitPutNetworkDNSResponse(w http.ResponseWriter) error { +func (response PutNetworkDNS202JSONResponse) VisitPutNetworkDNSResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(202) - return nil + + return json.NewEncoder(w).Encode(response) } type PutNetworkDNS400JSONResponse externalRef0.ErrorResponse @@ -275,7 +313,7 @@ type GetNetworkDNSByInterfaceResponseObject interface { VisitGetNetworkDNSByInterfaceResponse(w http.ResponseWriter) error } -type GetNetworkDNSByInterface200JSONResponse DNSConfigResponse +type GetNetworkDNSByInterface200JSONResponse DNSConfigCollectionResponse func (response GetNetworkDNSByInterface200JSONResponse) VisitGetNetworkDNSByInterfaceResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -329,7 +367,7 @@ type PostNetworkPingResponseObject interface { VisitPostNetworkPingResponse(w http.ResponseWriter) error } -type PostNetworkPing200JSONResponse PingResponse +type PostNetworkPing200JSONResponse PingCollectionResponse func (response PostNetworkPing200JSONResponse) VisitPostNetworkPingResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/network/network_dns_get_by_interface.go b/internal/api/network/network_dns_get_by_interface.go index d1379823..3aa58e4c 100644 --- a/internal/api/network/network_dns_get_by_interface.go +++ b/internal/api/network/network_dns_get_by_interface.go @@ -22,28 +22,12 @@ 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, @@ -74,7 +58,11 @@ func (n Network) GetNetworkDNSByInterface( return n.getNetworkDNSAll(ctx, request.InterfaceName) } - dnsConfig, err := n.JobClient.QueryNetworkDNS(ctx, hostname, request.InterfaceName) + dnsConfig, workerHostname, err := n.JobClient.QueryNetworkDNS( + ctx, + hostname, + request.InterfaceName, + ) if err != nil { errMsg := err.Error() return gen.GetNetworkDNSByInterface500JSONResponse{ @@ -86,8 +74,13 @@ func (n Network) GetNetworkDNSByInterface( servers := dnsConfig.DNSServers return gen.GetNetworkDNSByInterface200JSONResponse{ - SearchDomains: &searchDomains, - Servers: &servers, + Results: []gen.DNSConfigResponse{ + { + Hostname: workerHostname, + Servers: &servers, + SearchDomains: &searchDomains, + }, + }, }, nil } @@ -105,14 +98,17 @@ func (n Network) getNetworkDNSAll( } var responses []gen.DNSConfigResponse - for _, cfg := range results { + for host, cfg := range results { servers := cfg.DNSServers searchDomains := cfg.SearchDomains responses = append(responses, gen.DNSConfigResponse{ + Hostname: host, Servers: &servers, SearchDomains: &searchDomains, }) } - return dnsMultiResponse{Results: responses}, nil + return gen.GetNetworkDNSByInterface200JSONResponse{ + 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 index ae09b665..8bea46f3 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 @@ -75,11 +75,17 @@ func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByIn Return(&dns.Config{ DNSServers: []string{"8.8.8.8"}, SearchDomains: []string{"example.com"}, - }, nil) + }, "worker1", nil) return mock }, - wantCode: http.StatusOK, - wantContains: []string{`"servers"`, `"8.8.8.8"`, `"search_domains"`, `"example.com"`}, + wantCode: http.StatusOK, + wantContains: []string{ + `"results"`, + `"servers"`, + `"8.8.8.8"`, + `"search_domains"`, + `"example.com"`, + }, }, { name: "when non-alphanum interface name", 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 a555c16f..79143dff 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 @@ -71,13 +71,15 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() Return(&dns.Config{ DNSServers: []string{"192.168.1.1", "8.8.8.8"}, SearchDomains: []string{"example.com", "local.lan"}, - }, nil) + }, "worker1", nil) }, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { r, ok := resp.(gen.GetNetworkDNSByInterface200JSONResponse) s.True(ok) - s.Equal([]string{"192.168.1.1", "8.8.8.8"}, *r.Servers) - s.Equal([]string{"example.com", "local.lan"}, *r.SearchDomains) + s.Require().Len(r.Results, 1) + s.Equal([]string{"192.168.1.1", "8.8.8.8"}, *r.Results[0].Servers) + s.Equal([]string{"example.com", "local.lan"}, *r.Results[0].SearchDomains) + s.Equal("worker1", r.Results[0].Hostname) }, }, { @@ -125,7 +127,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() setupMock: func() { s.mockJobClient.EXPECT(). QueryNetworkDNS(gomock.Any(), gomock.Any(), "eth0"). - Return(nil, assert.AnError) + Return(nil, "", assert.AnError) }, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { _, ok := resp.(gen.GetNetworkDNSByInterface500JSONResponse) diff --git a/internal/api/network/network_dns_put_by_interface.go b/internal/api/network/network_dns_put_by_interface.go index 781736bd..c36d714c 100644 --- a/internal/api/network/network_dns_put_by_interface.go +++ b/internal/api/network/network_dns_put_by_interface.go @@ -22,35 +22,13 @@ 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, @@ -92,7 +70,13 @@ func (n Network) PutNetworkDNS( return n.putNetworkDNSAll(ctx, servers, searchDomains, interfaceName) } - err := n.JobClient.ModifyNetworkDNS(ctx, hostname, servers, searchDomains, interfaceName) + workerHostname, err := n.JobClient.ModifyNetworkDNS( + ctx, + hostname, + servers, + searchDomains, + interfaceName, + ) if err != nil { errMsg := err.Error() return gen.PutNetworkDNS500JSONResponse{ @@ -100,7 +84,14 @@ func (n Network) PutNetworkDNS( }, nil } - return gen.PutNetworkDNS202Response{}, nil + return gen.PutNetworkDNS202JSONResponse{ + Results: []gen.DNSUpdateResultItem{ + { + Hostname: workerHostname, + Status: gen.Ok, + }, + }, + }, nil } // putNetworkDNSAll handles _all broadcast for DNS modification. @@ -118,18 +109,21 @@ func (n Network) putNetworkDNSAll( }, nil } - var responses []dnsPutResult + var responses []gen.DNSUpdateResultItem for host, hostErr := range results { - r := dnsPutResult{ + item := gen.DNSUpdateResultItem{ Hostname: host, - Status: "ok", + Status: gen.Ok, } if hostErr != nil { - r.Status = "failed" - r.Error = fmt.Sprintf("%v", hostErr) + item.Status = gen.Failed + errStr := fmt.Sprintf("%v", hostErr) + item.Error = &errStr } - responses = append(responses, r) + responses = append(responses, item) } - return dnsPutMultiResponse{Results: responses}, nil + return gen.PutNetworkDNS202JSONResponse{ + 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 e4c5e661..03f03576 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 @@ -74,10 +74,11 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) + Return("worker1", nil) return mock }, - wantCode: http.StatusAccepted, + wantCode: http.StatusAccepted, + wantContains: []string{`"results"`, `"worker1"`, `"ok"`}, }, { name: "when missing interface name", 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 27d9268e..d032d439 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 @@ -77,11 +77,14 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { setupMock: func() { s.mockJobClient.EXPECT(). ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(nil) + Return("worker1", nil) }, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { - _, ok := resp.(gen.PutNetworkDNS202Response) + r, ok := resp.(gen.PutNetworkDNS202JSONResponse) s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("worker1", r.Results[0].Hostname) + s.Equal(gen.Ok, r.Results[0].Status) }, }, { @@ -131,7 +134,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { setupMock: func() { s.mockJobClient.EXPECT(). ModifyNetworkDNS(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). - Return(assert.AnError) + Return("", assert.AnError) }, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { _, ok := resp.(gen.PutNetworkDNS500JSONResponse) diff --git a/internal/api/network/network_ping_post.go b/internal/api/network/network_ping_post.go index 06cda058..42092a4d 100644 --- a/internal/api/network/network_ping_post.go +++ b/internal/api/network/network_ping_post.go @@ -22,9 +22,7 @@ package network import ( "context" - "encoding/json" "fmt" - "net/http" "time" "github.com/retr0h/osapi/internal/api/network/gen" @@ -33,20 +31,6 @@ import ( "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, @@ -76,7 +60,11 @@ func (n Network) PostNetworkPing( return n.postNetworkPingAll(ctx, request.Body.Address) } - pingResult, err := n.JobClient.QueryNetworkPing(ctx, hostname, request.Body.Address) + pingResult, workerHostname, err := n.JobClient.QueryNetworkPing( + ctx, + hostname, + request.Body.Address, + ) if err != nil { errMsg := err.Error() return gen.PostNetworkPing500JSONResponse{ @@ -84,7 +72,11 @@ func (n Network) PostNetworkPing( }, nil } - return buildPingResponse(pingResult), nil + return gen.PostNetworkPing200JSONResponse{ + Results: []gen.PingResponse{ + buildPingResponse(workerHostname, pingResult), + }, + }, nil } // postNetworkPingAll handles _all broadcast for ping. @@ -101,18 +93,22 @@ func (n Network) postNetworkPingAll( } var responses []gen.PingResponse - for _, r := range results { - responses = append(responses, gen.PingResponse(buildPingResponse(r))) + for host, r := range results { + responses = append(responses, buildPingResponse(host, r)) } - return pingMultiResponse{Results: responses}, nil + return gen.PostNetworkPing200JSONResponse{ + Results: responses, + }, nil } // buildPingResponse converts a ping.Result to the API response. func buildPingResponse( + hostname string, r *ping.Result, -) gen.PostNetworkPing200JSONResponse { - return gen.PostNetworkPing200JSONResponse{ +) gen.PingResponse { + return gen.PingResponse{ + Hostname: hostname, AvgRtt: durationToString(&r.AvgRTT), MaxRtt: durationToString(&r.MaxRTT), MinRtt: durationToString(&r.MinRTT), diff --git a/internal/api/network/network_ping_post_integration_test.go b/internal/api/network/network_ping_post_integration_test.go index de6f440a..ea310dee 100644 --- a/internal/api/network/network_ping_post_integration_test.go +++ b/internal/api/network/network_ping_post_integration_test.go @@ -83,11 +83,11 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { MinRTT: 10 * time.Millisecond, AvgRTT: 15 * time.Millisecond, MaxRTT: 20 * time.Millisecond, - }, nil) + }, "worker1", nil) return mock }, wantCode: http.StatusOK, - wantContains: []string{`"packets_sent":3`, `"packets_received":3`}, + wantContains: []string{`"results"`, `"packets_sent":3`, `"packets_received":3`}, }, { name: "when missing address", diff --git a/internal/api/network/network_ping_post_public_test.go b/internal/api/network/network_ping_post_public_test.go index 32f2010f..cbed8511 100644 --- a/internal/api/network/network_ping_post_public_test.go +++ b/internal/api/network/network_ping_post_public_test.go @@ -79,14 +79,16 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { MinRTT: 10 * time.Millisecond, AvgRTT: 15 * time.Millisecond, MaxRTT: 20 * time.Millisecond, - }, nil) + }, "worker1", nil) }, validateFunc: func(resp gen.PostNetworkPingResponseObject) { r, ok := resp.(gen.PostNetworkPing200JSONResponse) s.True(ok) - s.Equal(3, *r.PacketsSent) - s.Equal(3, *r.PacketsReceived) - s.Equal(0.0, *r.PacketLoss) + s.Require().Len(r.Results, 1) + s.Equal(3, *r.Results[0].PacketsSent) + s.Equal(3, *r.Results[0].PacketsReceived) + s.Equal(0.0, *r.Results[0].PacketLoss) + s.Equal("worker1", r.Results[0].Hostname) }, }, { @@ -132,7 +134,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { setupMock: func() { s.mockJobClient.EXPECT(). QueryNetworkPing(gomock.Any(), gomock.Any(), "1.1.1.1"). - Return(nil, assert.AnError) + Return(nil, "", assert.AnError) }, validateFunc: func(resp gen.PostNetworkPingResponseObject) { _, ok := resp.(gen.PostNetworkPing500JSONResponse) diff --git a/internal/api/system/gen/api.yaml b/internal/api/system/gen/api.yaml index 39c035e2..42a11d4d 100644 --- a/internal/api/system/gen/api.yaml +++ b/internal/api/system/gen/api.yaml @@ -55,7 +55,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SystemStatusResponse' + $ref: '#/components/schemas/SystemStatusCollectionResponse' '400': description: Invalid request parameters. content: @@ -104,7 +104,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HostnameResponse' + $ref: '#/components/schemas/HostnameCollectionResponse' '400': description: Invalid request parameters. content: @@ -253,6 +253,26 @@ components: required: - hostname + SystemStatusCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SystemStatusResponse' + required: + - results + + HostnameCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/HostnameResponse' + required: + - results + OSInfoResponse: type: object description: Operating system information. diff --git a/internal/api/system/gen/system.gen.go b/internal/api/system/gen/system.gen.go index 0243fb75..06a86f34 100644 --- a/internal/api/system/gen/system.gen.go +++ b/internal/api/system/gen/system.gen.go @@ -40,6 +40,11 @@ type DisksResponse = []DiskResponse // ErrorResponse defines model for ErrorResponse. type ErrorResponse = externalRef0.ErrorResponse +// HostnameCollectionResponse defines model for HostnameCollectionResponse. +type HostnameCollectionResponse struct { + Results []HostnameResponse `json:"results"` +} + // HostnameResponse The hostname of the system. type HostnameResponse struct { // Hostname The system's hostname. @@ -79,6 +84,11 @@ type OSInfoResponse struct { Version string `json:"version"` } +// SystemStatusCollectionResponse defines model for SystemStatusCollectionResponse. +type SystemStatusCollectionResponse struct { + Results []SystemStatusResponse `json:"results"` +} + // SystemStatusResponse defines model for SystemStatusResponse. type SystemStatusResponse struct { // Disks List of local disk usage information. @@ -208,7 +218,7 @@ type GetSystemHostnameResponseObject interface { VisitGetSystemHostnameResponse(w http.ResponseWriter) error } -type GetSystemHostname200JSONResponse HostnameResponse +type GetSystemHostname200JSONResponse HostnameCollectionResponse func (response GetSystemHostname200JSONResponse) VisitGetSystemHostnameResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") @@ -261,7 +271,7 @@ type GetSystemStatusResponseObject interface { VisitGetSystemStatusResponse(w http.ResponseWriter) error } -type GetSystemStatus200JSONResponse SystemStatusResponse +type GetSystemStatus200JSONResponse SystemStatusCollectionResponse func (response GetSystemStatus200JSONResponse) VisitGetSystemStatusResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") diff --git a/internal/api/system/system_hostname_get.go b/internal/api/system/system_hostname_get.go index b702605f..55d3c862 100644 --- a/internal/api/system/system_hostname_get.go +++ b/internal/api/system/system_hostname_get.go @@ -22,28 +22,12 @@ 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, @@ -67,7 +51,7 @@ func (s *System) GetSystemHostname( return s.getSystemHostnameAll(ctx) } - result, err := s.JobClient.QuerySystemHostname(ctx, hostname) + result, workerHostname, err := s.JobClient.QuerySystemHostname(ctx, hostname) if err != nil { errMsg := err.Error() return gen.GetSystemHostname500JSONResponse{ @@ -75,8 +59,15 @@ func (s *System) GetSystemHostname( }, nil } + displayHostname := result + if displayHostname == "" { + displayHostname = workerHostname + } + return gen.GetSystemHostname200JSONResponse{ - Hostname: result, + Results: []gen.HostnameResponse{ + {Hostname: displayHostname}, + }, }, nil } @@ -97,5 +88,7 @@ func (s *System) getSystemHostnameAll( responses = append(responses, gen.HostnameResponse{Hostname: h}) } - return hostnameMultiResponse{Results: responses}, nil + return gen.GetSystemHostname200JSONResponse{ + Results: responses, + }, nil } diff --git a/internal/api/system/system_hostname_get_integration_test.go b/internal/api/system/system_hostname_get_integration_test.go index 1cc24b8b..d1c2f290 100644 --- a/internal/api/system/system_hostname_get_integration_test.go +++ b/internal/api/system/system_hostname_get_integration_test.go @@ -74,11 +74,11 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). QuerySystemHostname(gomock.Any(), job.AnyHost). - Return("default-hostname", nil) + Return("default-hostname", "worker1", nil) return mock }, wantCode: http.StatusOK, - wantBody: `{"hostname":"default-hostname"}`, + wantBody: `{"results":[{"hostname":"default-hostname"}]}`, }, { name: "when job client errors", @@ -87,7 +87,7 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). QuerySystemHostname(gomock.Any(), job.AnyHost). - Return("", assert.AnError) + Return("", "", assert.AnError) return mock }, wantCode: http.StatusInternalServerError, diff --git a/internal/api/system/system_hostname_get_public_test.go b/internal/api/system/system_hostname_get_public_test.go index 717ba993..5d02073f 100644 --- a/internal/api/system/system_hostname_get_public_test.go +++ b/internal/api/system/system_hostname_get_public_test.go @@ -66,12 +66,13 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { setupMock: func() { s.mockJobClient.EXPECT(). QuerySystemHostname(gomock.Any(), gomock.Any()). - Return("my-hostname", nil) + Return("my-hostname", "worker1", nil) }, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { r, ok := resp.(gen.GetSystemHostname200JSONResponse) s.True(ok) - s.Equal("my-hostname", r.Hostname) + s.Require().Len(r.Results, 1) + s.Equal("my-hostname", r.Results[0].Hostname) }, }, { @@ -94,7 +95,7 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { setupMock: func() { s.mockJobClient.EXPECT(). QuerySystemHostname(gomock.Any(), gomock.Any()). - Return("", assert.AnError) + Return("", "", assert.AnError) }, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { _, ok := resp.(gen.GetSystemHostname500JSONResponse) diff --git a/internal/api/system/system_status_get.go b/internal/api/system/system_status_get.go index 60e16035..8220ce76 100644 --- a/internal/api/system/system_status_get.go +++ b/internal/api/system/system_status_get.go @@ -22,9 +22,7 @@ package system import ( "context" - "encoding/json" "fmt" - "net/http" "time" "github.com/retr0h/osapi/internal/api/system/gen" @@ -32,20 +30,6 @@ import ( "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, @@ -79,7 +63,9 @@ func (s *System) GetSystemStatus( resp := buildSystemStatusResponse(status) - return gen.GetSystemStatus200JSONResponse(*resp), nil + return gen.GetSystemStatus200JSONResponse{ + Results: []gen.SystemStatusResponse{*resp}, + }, nil } // getSystemStatusAll handles _all broadcast for system status. @@ -99,7 +85,9 @@ func (s *System) getSystemStatusAll( responses = append(responses, *buildSystemStatusResponse(status)) } - return systemStatusMultiResponse{Results: responses}, nil + return gen.GetSystemStatus200JSONResponse{ + Results: responses, + }, nil } // buildSystemStatusResponse converts a job.SystemStatusResponse to the API response. diff --git a/internal/api/system/system_status_get_integration_test.go b/internal/api/system/system_status_get_integration_test.go index 9526aeef..645488bf 100644 --- a/internal/api/system/system_status_get_integration_test.go +++ b/internal/api/system/system_status_get_integration_test.go @@ -110,30 +110,34 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { wantCode: http.StatusOK, wantBody: ` { - "disks": [ + "results": [ { - "free": 250000000000, - "name": "/dev/disk1", - "total": 500000000000, - "used": 250000000000 + "disks": [ + { + "free": 250000000000, + "name": "/dev/disk1", + "total": 500000000000, + "used": 250000000000 + } + ], + "hostname": "default-hostname", + "load_average": { + "1min": 1, + "5min": 0.5, + "15min": 0.2 + }, + "memory": { + "free": 4194304, + "total": 8388608, + "used": 2097152 + }, + "os_info": { + "distribution": "Ubuntu", + "version": "24.04" + }, + "uptime": "0 days, 5 hours, 0 minutes" } - ], - "hostname": "default-hostname", - "load_average": { - "1min": 1, - "5min": 0.5, - "15min": 0.2 - }, - "memory": { - "free": 4194304, - "total": 8388608, - "used": 2097152 - }, - "os_info": { - "distribution": "Ubuntu", - "version": "24.04" - }, - "uptime": "0 days, 5 hours, 0 minutes" + ] } `, }, diff --git a/internal/client/gen/api.yaml b/internal/client/gen/api.yaml index 409c2bb7..ac453e4e 100644 --- a/internal/client/gen/api.yaml +++ b/internal/client/gen/api.yaml @@ -366,7 +366,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/PingResponse' + $ref: '#/components/schemas/PingCollectionResponse' '400': description: Invalid request payload. content: @@ -429,7 +429,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/DNSConfigResponse' + $ref: '#/components/schemas/DNSConfigCollectionResponse' '400': description: Invalid interface name provided. content: @@ -483,6 +483,10 @@ paths: responses: '202': description: DNS servers update successfully accepted. + content: + application/json: + schema: + $ref: '#/components/schemas/DNSUpdateCollectionResponse' '400': description: Invalid input. content: @@ -534,7 +538,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/SystemStatusResponse' + $ref: '#/components/schemas/SystemStatusCollectionResponse' '401': description: Unauthorized - API key required content: @@ -578,7 +582,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/HostnameResponse' + $ref: '#/components/schemas/HostnameCollectionResponse' '401': description: Unauthorized - API key required content: @@ -759,6 +763,9 @@ components: PingResponse: type: object properties: + hostname: + type: string + description: The hostname of the worker that executed the ping. packets_sent: type: integer description: Number of packets sent. @@ -784,9 +791,23 @@ components: type: string description: Maximum round-trip time as a string in Go's time.Duration format. example: 24.309240ms + required: + - hostname + PingCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/PingResponse' + required: + - results DNSConfigResponse: type: object properties: + hostname: + type: string + description: The hostname of the worker that served this DNS config. servers: type: array description: List of configured DNS servers. @@ -798,6 +819,41 @@ components: description: List of search domains. items: type: string + required: + - hostname + DNSConfigCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/DNSConfigResponse' + required: + - results + DNSUpdateResultItem: + type: object + properties: + hostname: + type: string + status: + type: string + enum: + - ok + - failed + error: + type: string + required: + - hostname + - status + DNSUpdateCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/DNSUpdateResultItem' + required: + - results DNSConfigUpdateRequest: type: object properties: @@ -895,6 +951,24 @@ components: description: List of local disk usage information. items: $ref: '#/components/schemas/DiskResponse' + SystemStatusCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/SystemStatusResponse' + required: + - results + HostnameCollectionResponse: + type: object + properties: + results: + type: array + items: + $ref: '#/components/schemas/HostnameResponse' + required: + - results SystemStatusResponse: type: object properties: diff --git a/internal/client/gen/client.gen.go b/internal/client/gen/client.gen.go index ef69c9ef..153d50f2 100644 --- a/internal/client/gen/client.gen.go +++ b/internal/client/gen/client.gen.go @@ -20,6 +20,12 @@ const ( BearerAuthScopes = "BearerAuth.Scopes" ) +// Defines values for DNSUpdateResultItemStatus. +const ( + Failed DNSUpdateResultItemStatus = "failed" + Ok DNSUpdateResultItemStatus = "ok" +) + // CreateJobRequest defines model for CreateJobRequest. type CreateJobRequest struct { // Operation The operation to perform, as a JSON object. @@ -44,8 +50,16 @@ type CreateJobResponse struct { Timestamp *string `json:"timestamp,omitempty"` } +// DNSConfigCollectionResponse defines model for DNSConfigCollectionResponse. +type DNSConfigCollectionResponse struct { + Results []DNSConfigResponse `json:"results"` +} + // DNSConfigResponse defines model for DNSConfigResponse. type DNSConfigResponse struct { + // Hostname The hostname of the worker that served this DNS config. + Hostname string `json:"hostname"` + // SearchDomains List of search domains. SearchDomains *[]string `json:"search_domains,omitempty"` @@ -65,6 +79,21 @@ type DNSConfigUpdateRequest struct { Servers *[]string `json:"servers,omitempty" validate:"required_without=SearchDomains,omitempty,dive,ip,min=1"` } +// DNSUpdateCollectionResponse defines model for DNSUpdateCollectionResponse. +type DNSUpdateCollectionResponse struct { + Results []DNSUpdateResultItem `json:"results"` +} + +// DNSUpdateResultItem defines model for DNSUpdateResultItem. +type DNSUpdateResultItem struct { + Error *string `json:"error,omitempty"` + Hostname string `json:"hostname"` + Status DNSUpdateResultItemStatus `json:"status"` +} + +// DNSUpdateResultItemStatus defines model for DNSUpdateResultItem.Status. +type DNSUpdateResultItemStatus string + // DiskResponse Local disk usage information. type DiskResponse struct { // Free Free disk space in bytes. @@ -95,6 +124,11 @@ type ErrorResponse struct { Error *string `json:"error,omitempty"` } +// HostnameCollectionResponse defines model for HostnameCollectionResponse. +type HostnameCollectionResponse struct { + Results []HostnameResponse `json:"results"` +} + // HostnameResponse The hostname of the system. type HostnameResponse struct { // Hostname The system's hostname. @@ -192,11 +226,19 @@ type OSInfoResponse struct { Version string `json:"version"` } +// PingCollectionResponse defines model for PingCollectionResponse. +type PingCollectionResponse struct { + Results []PingResponse `json:"results"` +} + // PingResponse defines model for PingResponse. type PingResponse struct { // AvgRtt Average round-trip time as a string in Go's time.Duration format. AvgRtt *string `json:"avg_rtt,omitempty"` + // Hostname The hostname of the worker that executed the ping. + Hostname string `json:"hostname"` + // MaxRtt Maximum round-trip time as a string in Go's time.Duration format. MaxRtt *string `json:"max_rtt,omitempty"` @@ -228,6 +270,11 @@ type QueueStatsResponse struct { TotalJobs *int `json:"total_jobs,omitempty"` } +// SystemStatusCollectionResponse defines model for SystemStatusCollectionResponse. +type SystemStatusCollectionResponse struct { + Results []SystemStatusResponse `json:"results"` +} + // SystemStatusResponse defines model for SystemStatusResponse. type SystemStatusResponse struct { // Disks List of local disk usage information. @@ -1359,6 +1406,7 @@ func (r GetJobByIDResponse) StatusCode() int { type PutNetworkDNSResponse struct { Body []byte HTTPResponse *http.Response + JSON202 *DNSUpdateCollectionResponse JSON400 *ErrorResponse JSON401 *ErrorResponse JSON403 *ErrorResponse @@ -1384,7 +1432,7 @@ func (r PutNetworkDNSResponse) StatusCode() int { type GetNetworkDNSByInterfaceResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *DNSConfigResponse + JSON200 *DNSConfigCollectionResponse JSON400 *ErrorResponse JSON401 *ErrorResponse JSON403 *ErrorResponse @@ -1410,7 +1458,7 @@ func (r GetNetworkDNSByInterfaceResponse) StatusCode() int { type PostNetworkPingResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *PingResponse + JSON200 *PingCollectionResponse JSON400 *ErrorResponse JSON401 *ErrorResponse JSON403 *ErrorResponse @@ -1436,7 +1484,7 @@ func (r PostNetworkPingResponse) StatusCode() int { type GetSystemHostnameResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *HostnameResponse + JSON200 *HostnameCollectionResponse JSON401 *ErrorResponse JSON403 *ErrorResponse JSON500 *ErrorResponse @@ -1461,7 +1509,7 @@ func (r GetSystemHostnameResponse) StatusCode() int { type GetSystemStatusResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *SystemStatusResponse + JSON200 *SystemStatusCollectionResponse JSON401 *ErrorResponse JSON403 *ErrorResponse JSON500 *ErrorResponse @@ -1947,6 +1995,13 @@ func ParsePutNetworkDNSResponse(rsp *http.Response) (*PutNetworkDNSResponse, err } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest DNSUpdateCollectionResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest ErrorResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -1995,7 +2050,7 @@ func ParseGetNetworkDNSByInterfaceResponse(rsp *http.Response) (*GetNetworkDNSBy switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DNSConfigResponse + var dest DNSConfigCollectionResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -2049,7 +2104,7 @@ func ParsePostNetworkPingResponse(rsp *http.Response) (*PostNetworkPingResponse, switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PingResponse + var dest PingCollectionResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -2103,7 +2158,7 @@ func ParseGetSystemHostnameResponse(rsp *http.Response) (*GetSystemHostnameRespo switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest HostnameResponse + var dest HostnameCollectionResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -2150,7 +2205,7 @@ func ParseGetSystemStatusResponse(rsp *http.Response) (*GetSystemStatusResponse, switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest SystemStatusResponse + var dest SystemStatusCollectionResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } diff --git a/internal/job/client/modify.go b/internal/job/client/modify.go index cd90b8a7..a3b3cc89 100644 --- a/internal/job/client/modify.go +++ b/internal/job/client/modify.go @@ -35,7 +35,7 @@ func (c *Client) ModifyNetworkDNS( servers []string, searchDomains []string, iface string, -) error { +) (string, error) { data, _ := json.Marshal(map[string]interface{}{ "servers": servers, "search_domains": searchDomains, @@ -51,14 +51,14 @@ func (c *Client) ModifyNetworkDNS( subject := job.BuildModifySubject(hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { - return fmt.Errorf("failed to publish and wait: %w", err) + return "", fmt.Errorf("failed to publish and wait: %w", err) } if resp.Status == "failed" { - return fmt.Errorf("job failed: %s", resp.Error) + return "", fmt.Errorf("job failed: %s", resp.Error) } - return nil + return resp.Hostname, nil } // ModifyNetworkDNSAny modifies DNS configuration on any available host. @@ -67,7 +67,7 @@ func (c *Client) ModifyNetworkDNSAny( servers []string, searchDomains []string, iface string, -) error { +) (string, error) { return c.ModifyNetworkDNS(ctx, job.AnyHost, servers, searchDomains, iface) } diff --git a/internal/job/client/modify_public_test.go b/internal/job/client/modify_public_test.go index a6a92ac1..3d8bddb2 100644 --- a/internal/job/client/modify_public_test.go +++ b/internal/job/client/modify_public_test.go @@ -124,7 +124,7 @@ func (s *ModifyPublicTestSuite) TestModifyNetworkDNS() { tt.mockError, ) - err := s.jobsClient.ModifyNetworkDNS( + _, err := s.jobsClient.ModifyNetworkDNS( s.ctx, tt.hostname, tt.servers, @@ -188,7 +188,12 @@ func (s *ModifyPublicTestSuite) TestModifyNetworkDNSAny() { tt.mockError, ) - err := s.jobsClient.ModifyNetworkDNSAny(s.ctx, tt.servers, tt.searchDomains, tt.iface) + _, err := s.jobsClient.ModifyNetworkDNSAny( + s.ctx, + tt.servers, + tt.searchDomains, + tt.iface, + ) if tt.expectError { s.Error(err) diff --git a/internal/job/client/query.go b/internal/job/client/query.go index 0ce4909f..14863d8e 100644 --- a/internal/job/client/query.go +++ b/internal/job/client/query.go @@ -64,7 +64,7 @@ func (c *Client) QuerySystemStatus( func (c *Client) QuerySystemHostname( ctx context.Context, hostname string, -) (string, error) { +) (string, string, error) { req := &job.Request{ Type: job.TypeQuery, Category: "system", @@ -75,21 +75,21 @@ func (c *Client) QuerySystemHostname( subject := job.BuildQuerySubject(hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { - return "", fmt.Errorf("failed to publish and wait: %w", err) + return "", "", fmt.Errorf("failed to publish and wait: %w", err) } if resp.Status == "failed" { - return "", fmt.Errorf("job failed: %s", resp.Error) + return "", "", fmt.Errorf("job failed: %s", resp.Error) } var result struct { Hostname string `json:"hostname"` } if err := json.Unmarshal(resp.Data, &result); err != nil { - return "", fmt.Errorf("failed to unmarshal hostname response: %w", err) + return "", "", fmt.Errorf("failed to unmarshal hostname response: %w", err) } - return result.Hostname, nil + return result.Hostname, resp.Hostname, nil } // QueryNetworkDNS queries DNS configuration from a specific hostname. @@ -97,7 +97,7 @@ func (c *Client) QueryNetworkDNS( ctx context.Context, hostname string, iface string, -) (*dns.Config, error) { +) (*dns.Config, string, error) { data, _ := json.Marshal(map[string]interface{}{ "interface": iface, }) @@ -111,19 +111,19 @@ func (c *Client) QueryNetworkDNS( subject := job.BuildQuerySubject(hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { - return nil, fmt.Errorf("failed to publish and wait: %w", err) + return nil, "", fmt.Errorf("failed to publish and wait: %w", err) } if resp.Status == "failed" { - return nil, fmt.Errorf("job failed: %s", resp.Error) + return nil, "", fmt.Errorf("job failed: %s", resp.Error) } var result dns.Config if err := json.Unmarshal(resp.Data, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal DNS response: %w", err) + return nil, "", fmt.Errorf("failed to unmarshal DNS response: %w", err) } - return &result, nil + return &result, resp.Hostname, nil } // QuerySystemStatusAny queries system status from any available host. @@ -176,7 +176,7 @@ func (c *Client) QueryNetworkPing( ctx context.Context, hostname string, address string, -) (*ping.Result, error) { +) (*ping.Result, string, error) { data, _ := json.Marshal(map[string]interface{}{ "address": address, }) @@ -190,26 +190,26 @@ func (c *Client) QueryNetworkPing( subject := job.BuildQuerySubject(hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { - return nil, fmt.Errorf("failed to publish and wait: %w", err) + return nil, "", fmt.Errorf("failed to publish and wait: %w", err) } if resp.Status == "failed" { - return nil, fmt.Errorf("job failed: %s", resp.Error) + return nil, "", fmt.Errorf("job failed: %s", resp.Error) } var result ping.Result if err := json.Unmarshal(resp.Data, &result); err != nil { - return nil, fmt.Errorf("failed to unmarshal ping response: %w", err) + return nil, "", fmt.Errorf("failed to unmarshal ping response: %w", err) } - return &result, nil + return &result, resp.Hostname, nil } // QueryNetworkPingAny pings a host from any available hostname. func (c *Client) QueryNetworkPingAny( ctx context.Context, address string, -) (*ping.Result, error) { +) (*ping.Result, string, error) { return c.QueryNetworkPing(ctx, job.AnyHost, address) } diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go index 003f6960..b41b6025 100644 --- a/internal/job/client/query_public_test.go +++ b/internal/job/client/query_public_test.go @@ -243,7 +243,7 @@ func (s *QueryPublicTestSuite) TestQuerySystemHostname() { tt.mockError, ) - result, err := s.jobsClient.QuerySystemHostname(s.ctx, tt.hostname) + result, _, err := s.jobsClient.QuerySystemHostname(s.ctx, tt.hostname) if tt.expectError { s.Error(err) @@ -362,7 +362,7 @@ func (s *QueryPublicTestSuite) TestQueryNetworkDNS() { tt.mockError, ) - result, err := s.jobsClient.QueryNetworkDNS(s.ctx, tt.hostname, tt.iface) + result, _, err := s.jobsClient.QueryNetworkDNS(s.ctx, tt.hostname, tt.iface) if tt.expectError { s.Error(err) @@ -450,7 +450,7 @@ func (s *QueryPublicTestSuite) TestQueryNetworkPing() { tt.mockError, ) - result, err := s.jobsClient.QueryNetworkPing(s.ctx, tt.hostname, tt.address) + result, _, err := s.jobsClient.QueryNetworkPing(s.ctx, tt.hostname, tt.address) if tt.expectError { s.Error(err) @@ -507,7 +507,7 @@ func (s *QueryPublicTestSuite) TestQueryNetworkPingAny() { tt.mockError, ) - result, err := s.jobsClient.QueryNetworkPingAny(s.ctx, tt.address) + result, _, err := s.jobsClient.QueryNetworkPingAny(s.ctx, tt.address) if tt.expectError { s.Error(err) @@ -646,7 +646,7 @@ func (s *QueryPublicTestSuite) TestPublishAndWaitErrorPaths() { tt.opts, ) - result, err := jobsClient.QuerySystemHostname(s.ctx, "server1") + result, _, err := jobsClient.QuerySystemHostname(s.ctx, "server1") if tt.expectError { s.Error(err) diff --git a/internal/job/client/types.go b/internal/job/client/types.go index c3f1c8a0..9be3fb6a 100644 --- a/internal/job/client/types.go +++ b/internal/job/client/types.go @@ -65,7 +65,7 @@ type JobClient interface { QuerySystemHostname( ctx context.Context, hostname string, - ) (string, error) + ) (string, string, error) QuerySystemHostnameAll( ctx context.Context, ) (map[string]string, error) @@ -73,7 +73,7 @@ type JobClient interface { ctx context.Context, hostname string, iface string, - ) (*dns.Config, error) + ) (*dns.Config, string, error) QueryNetworkDNSAll( ctx context.Context, iface string, @@ -86,13 +86,13 @@ type JobClient interface { servers []string, searchDomains []string, iface string, - ) error + ) (string, error) ModifyNetworkDNSAny( ctx context.Context, servers []string, searchDomains []string, iface string, - ) error + ) (string, error) ModifyNetworkDNSAll( ctx context.Context, servers []string, @@ -103,11 +103,11 @@ type JobClient interface { ctx context.Context, hostname string, address string, - ) (*ping.Result, error) + ) (*ping.Result, string, error) QueryNetworkPingAny( ctx context.Context, address string, - ) (*ping.Result, error) + ) (*ping.Result, string, error) QueryNetworkPingAll( ctx context.Context, address string, diff --git a/internal/job/client/worker.go b/internal/job/client/worker.go index c072c66d..8ad41607 100644 --- a/internal/job/client/worker.go +++ b/internal/job/client/worker.go @@ -98,6 +98,7 @@ func (c *Client) WriteJobResponse( Status: job.Status(status), Data: responseData, Error: errorMsg, + Hostname: hostname, Timestamp: time.Now(), } diff --git a/internal/job/mocks/job_client.gen.go b/internal/job/mocks/job_client.gen.go index d1b3a6ef..35a82441 100644 --- a/internal/job/mocks/job_client.gen.go +++ b/internal/job/mocks/job_client.gen.go @@ -173,11 +173,12 @@ func (mr *MockJobClientMockRecorder) ListWorkers(arg0 interface{}) *gomock.Call } // ModifyNetworkDNS mocks base method. -func (m *MockJobClient) ModifyNetworkDNS(arg0 context.Context, arg1 string, arg2, arg3 []string, arg4 string) error { +func (m *MockJobClient) ModifyNetworkDNS(arg0 context.Context, arg1 string, arg2, arg3 []string, arg4 string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ModifyNetworkDNS", arg0, arg1, arg2, arg3, arg4) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 } // ModifyNetworkDNS indicates an expected call of ModifyNetworkDNS. @@ -202,11 +203,12 @@ func (mr *MockJobClientMockRecorder) ModifyNetworkDNSAll(arg0, arg1, arg2, arg3 } // ModifyNetworkDNSAny mocks base method. -func (m *MockJobClient) ModifyNetworkDNSAny(arg0 context.Context, arg1, arg2 []string, arg3 string) error { +func (m *MockJobClient) ModifyNetworkDNSAny(arg0 context.Context, arg1, arg2 []string, arg3 string) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ModifyNetworkDNSAny", arg0, arg1, arg2, arg3) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 } // ModifyNetworkDNSAny indicates an expected call of ModifyNetworkDNSAny. @@ -216,12 +218,13 @@ func (mr *MockJobClientMockRecorder) ModifyNetworkDNSAny(arg0, arg1, arg2, arg3 } // QueryNetworkDNS mocks base method. -func (m *MockJobClient) QueryNetworkDNS(arg0 context.Context, arg1, arg2 string) (*dns.Config, error) { +func (m *MockJobClient) QueryNetworkDNS(arg0 context.Context, arg1, arg2 string) (*dns.Config, string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "QueryNetworkDNS", arg0, arg1, arg2) ret0, _ := ret[0].(*dns.Config) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // QueryNetworkDNS indicates an expected call of QueryNetworkDNS. @@ -246,12 +249,13 @@ func (mr *MockJobClientMockRecorder) QueryNetworkDNSAll(arg0, arg1 interface{}) } // QueryNetworkPing mocks base method. -func (m *MockJobClient) QueryNetworkPing(arg0 context.Context, arg1, arg2 string) (*ping.Result, error) { +func (m *MockJobClient) QueryNetworkPing(arg0 context.Context, arg1, arg2 string) (*ping.Result, string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "QueryNetworkPing", arg0, arg1, arg2) ret0, _ := ret[0].(*ping.Result) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // QueryNetworkPing indicates an expected call of QueryNetworkPing. @@ -276,12 +280,13 @@ func (mr *MockJobClientMockRecorder) QueryNetworkPingAll(arg0, arg1 interface{}) } // QueryNetworkPingAny mocks base method. -func (m *MockJobClient) QueryNetworkPingAny(arg0 context.Context, arg1 string) (*ping.Result, error) { +func (m *MockJobClient) QueryNetworkPingAny(arg0 context.Context, arg1 string) (*ping.Result, string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "QueryNetworkPingAny", arg0, arg1) ret0, _ := ret[0].(*ping.Result) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // QueryNetworkPingAny indicates an expected call of QueryNetworkPingAny. @@ -291,12 +296,13 @@ func (mr *MockJobClientMockRecorder) QueryNetworkPingAny(arg0, arg1 interface{}) } // QuerySystemHostname mocks base method. -func (m *MockJobClient) QuerySystemHostname(arg0 context.Context, arg1 string) (string, error) { +func (m *MockJobClient) QuerySystemHostname(arg0 context.Context, arg1 string) (string, string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "QuerySystemHostname", arg0, arg1) ret0, _ := ret[0].(string) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret1, _ := ret[1].(string) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // QuerySystemHostname indicates an expected call of QuerySystemHostname. From 7ccd2fc8d330f9eb6209d170a32a1593517c5263 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: Wed, 18 Feb 2026 08:46:05 -0800 Subject: [PATCH 2/2] feat(job): Add hierarchical label-based worker routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add label-based targeting so admins can route jobs to groups of servers using hierarchical dot-separated labels (e.g., --target group:web.dev). Workers subscribe to every prefix level of their label values via pure NATS subject routing — no application-layer filtering needed. Key changes: - Add Labels field to worker config and WorkerInfo discovery response - Add ParseTarget, BuildSubjectFromTarget, IsBroadcastTarget, ValidateLabel, and BuildLabelSubjects to subject routing layer - Update all 5 API handlers to route label targets to broadcast path - Update CLI flag descriptions, OpenAPI specs, and CLI usage docs - Refactor publishAndCollect to share timeout return path and make BroadcastQuietPeriod configurable - Achieve 100% test coverage on job, job/client, job/worker, api/system, and api/network packages - Replace ASCII box diagrams in architecture docs with clean markdown 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .tasks/backlog/2026-02-18-cli-docs-audit.md | 30 ++ .../2026-02-17-label-based-worker-routing.md | 0 cmd/client.go | 2 +- docs/docs/sidebar/architecture.md | 193 +++++------ .../usage/cli/client/network/dns/get.md | 6 + .../usage/cli/client/network/dns/update.md | 9 + .../usage/cli/client/network/ping/ping.md | 6 + .../usage/cli/client/system/hostname.md | 6 + .../sidebar/usage/cli/client/system/status.md | 7 + internal/api/network/gen/api.yaml | 9 +- internal/api/network/gen/network.gen.go | 6 +- .../network/network_dns_get_by_interface.go | 11 +- ...k_dns_get_by_interface_integration_test.go | 2 +- ...etwork_dns_get_by_interface_public_test.go | 4 +- .../network/network_dns_put_by_interface.go | 17 +- ...k_dns_put_by_interface_integration_test.go | 2 +- ...etwork_dns_put_by_interface_public_test.go | 6 +- internal/api/network/network_ping_post.go | 11 +- .../network_ping_post_integration_test.go | 2 +- .../network/network_ping_post_public_test.go | 4 +- internal/api/system/gen/api.yaml | 6 +- internal/api/system/gen/system.gen.go | 4 +- internal/api/system/system_hostname_get.go | 11 +- .../system_hostname_get_integration_test.go | 2 +- .../system/system_hostname_get_public_test.go | 19 +- internal/api/system/system_status_get.go | 11 +- .../system_status_get_integration_test.go | 2 +- .../system/system_status_get_public_test.go | 4 +- internal/client/gen/api.yaml | 15 +- internal/client/gen/client.gen.go | 10 +- internal/config/types.go | 2 + internal/job/client/client.go | 49 +-- internal/job/client/modify.go | 20 +- internal/job/client/modify_public_test.go | 2 +- internal/job/client/query.go | 99 ++++-- internal/job/client/query_public_test.go | 33 +- internal/job/client/types.go | 25 ++ internal/job/mocks/job_client.gen.go | 75 +++++ internal/job/subjects.go | 148 ++++++++- internal/job/subjects_public_test.go | 306 +++++++++++++++++- internal/job/types.go | 2 + internal/job/worker/consumer.go | 54 +++- internal/job/worker/consumer_test.go | 52 +++ internal/job/worker/processor.go | 6 +- internal/job/worker/processor_test.go | 18 ++ internal/job/worker/server.go | 1 + osapi.yaml | 2 + 47 files changed, 1089 insertions(+), 222 deletions(-) create mode 100644 .tasks/backlog/2026-02-18-cli-docs-audit.md rename .tasks/{backlog => done}/2026-02-17-label-based-worker-routing.md (100%) diff --git a/.tasks/backlog/2026-02-18-cli-docs-audit.md b/.tasks/backlog/2026-02-18-cli-docs-audit.md new file mode 100644 index 00000000..635f06aa --- /dev/null +++ b/.tasks/backlog/2026-02-18-cli-docs-audit.md @@ -0,0 +1,30 @@ +--- +title: Audit CLI commands and docs for consistency +status: backlog +created: 2026-02-18 +updated: 2026-02-18 +--- + +## Objective + +Audit all CLI commands and ensure documentation is up to date and consistent +across every command. After the label-based routing feature, several docs were +updated but there may be inconsistencies remaining. + +## Scope + +- Review every `cmd/client_*.go` file for flag descriptions, defaults, and help + text +- Cross-reference each CLI command against its corresponding doc in + `docs/docs/sidebar/usage/cli/client/` +- Verify flag tables, example output, and targeting examples are consistent +- Ensure `--target` flag description and examples use the new hierarchical label + syntax (`group:web.dev`) everywhere +- Check that OpenAPI spec parameter descriptions match CLI help text +- Verify the architecture docs accurately describe all supported target types + +## Notes + +- The label-based routing feature changed target syntax and added hierarchical + labels — make sure all references use the new format +- Check for any stale references to old flat label syntax (e.g., `role:web`) diff --git a/.tasks/backlog/2026-02-17-label-based-worker-routing.md b/.tasks/done/2026-02-17-label-based-worker-routing.md similarity index 100% rename from .tasks/backlog/2026-02-17-label-based-worker-routing.md rename to .tasks/done/2026-02-17-label-based-worker-routing.md diff --git a/cmd/client.go b/cmd/client.go index ce0a1add..4da5265d 100644 --- a/cmd/client.go +++ b/cmd/client.go @@ -63,7 +63,7 @@ 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)") + StringP("target", "T", "_any", "Target: _any, _all, hostname, or label (group:web.dev)") _ = viper.BindPFlag("api.client.url", clientCmd.PersistentFlags().Lookup("url")) } diff --git a/docs/docs/sidebar/architecture.md b/docs/docs/sidebar/architecture.md index e28d2617..9d095bf8 100644 --- a/docs/docs/sidebar/architecture.md +++ b/docs/docs/sidebar/architecture.md @@ -29,54 +29,33 @@ routing, and comprehensive job lifecycle management. ### Core Components -``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ REST API │ │ Jobs CLI │ │ Job Workers │ -│ │ │ │ │ │ -│ • Create Jobs │ │ • Add Jobs │ │ • Process Jobs │ -│ • Query Status │ │ • List Jobs │ │ • Update Status │ -│ • Return Results│ │ • Get Details │ │ • Store Results │ -└─────────────────┘ │ • Status View │ └─────────────────┘ - │ └─────────────────┘ │ - │ │ │ - └───────────────────────┼─────────────────────────┘ - │ - v - ┌─────────────────────────┐ - │ Job Client Layer │ - │ │ - │ • CreateJob() │ <--- Business Logic - │ • GetQueueStats() │ <--- Abstraction - │ • GetJobStatus() │ <--- Type Safety - │ • ListJobs() │ - └─────────────────────────┘ - │ - v - ┌─────────────────────────┐ - │ NATS JetStream │ - │ │ - │ KV Store (job-queue) │ <--- Job Persistence - │ Stream (JOBS) │ <--- Worker Notifications - │ KV Store (job-results) │ <--- Result Storage - └─────────────────────────┘ -``` +The system has three entry points that all funnel through a shared client layer +into NATS JetStream: -### Job Flow Diagram +- **REST API** — Creates jobs, queries status, returns results +- **Jobs CLI** — Adds jobs, lists/inspects queue, monitors status +- **Job Workers** — Processes jobs, updates status, stores results -``` -1. Job Creation - API/CLI → Job Client → KV Store → Stream Notification - -2. Job Processing - Worker ← Stream Notification - Worker → Get Job from KV - Worker → Update Status in KV - Worker → Process Operation - Worker → Store Result in KV - -3. Status Query - API/CLI → Job Client → Read from KV -``` +All three use the **Job Client Layer** (`internal/job/client/`), which provides +type-safe business logic operations (`CreateJob`, `GetQueueStats`, +`GetJobStatus`, `ListJobs`) on top of NATS JetStream. + +**NATS JetStream** provides three storage backends: + +| Store | Purpose | +| ------------------ | ----------------------------------------------------------- | +| KV `job-queue` | Job persistence (immutable job definitions + status events) | +| Stream `JOBS` | Worker notifications (subject-routed job IDs) | +| KV `job-responses` | Result storage (worker responses keyed by request ID) | + +### Job Flow + +1. **Job Creation** — API/CLI calls Job Client, which stores the job in KV and + publishes a notification to the stream +2. **Job Processing** — Worker receives notification from the stream, fetches + the immutable job from KV, writes status events, executes the operation, and + stores the result in KV +3. **Status Query** — API/CLI reads computed status from KV events ## NATS Configuration @@ -111,16 +90,19 @@ AckWait: 30s ## Subject Hierarchy -The system uses simplified subjects for efficient routing: +The system uses structured subjects for efficient routing: ``` -jobs.{type}.{hostname} +jobs.{type}.{routing_type}.{value...} Examples: -- jobs.query._any -- jobs.query.server1 -- jobs.modify._all -- jobs.modify._any +- jobs.query._any — load-balanced +- jobs.query._all — broadcast all +- jobs.query.host.server1 — direct to host +- jobs.query.label.group.web — label group (role level) +- jobs.query.label.group.web.dev — label group (role+env level) +- jobs.modify._all — broadcast modify +- jobs.modify.label.group.web — label group modify ``` ### Semantic Routing Rules @@ -128,7 +110,7 @@ Examples: Operations are automatically routed to query or modify subjects based on their type suffix: -- **Query operations** (read-only) → `jobs.query.{hostname}`: +- **Query operations** (read-only) → `jobs.query.{target}`: - `.get` - Retrieve current state - `.query` - Query information @@ -137,7 +119,7 @@ type suffix: - `.do` - Perform read-only actions (e.g., ping) - `system.*` - All system operations are read-only -- **Modify operations** (state-changing) → `jobs.modify.{hostname}`: +- **Modify operations** (state-changing) → `jobs.modify.{target}`: - `.update` - Update configuration - `.set` - Set new values - `.create` - Create resources @@ -147,11 +129,55 @@ type suffix: The operation details (category, operation, data) are specified in the JSON payload, not the subject. -### Special Hostnames +### Target Types -- `_any`: Route to any available worker +- `_any`: Route to any available worker (load-balanced via queue group) - `_all`: Route to all workers (broadcast) -- `{hostname}`: Route to specific worker +- `{hostname}`: Route to a specific worker (e.g., `server1`) +- `{key}:{value}`: Route to all workers with a matching label (e.g., + `group:web`). Label targets use broadcast semantics — all matching workers + receive the message. Values can be hierarchical with dot separators for prefix + matching (e.g., `group:web.dev`). + +### Label-Based Routing + +Workers can be configured with hierarchical labels for group targeting. Label +values use dot-separated segments, and workers automatically subscribe to every +prefix level: + +```yaml +job: + worker: + hostname: web-01 + labels: + group: web.dev.us-east +``` + +A worker with the above config subscribes to these NATS subjects: + +``` +jobs.*.host.web-01 — direct +jobs.*._any — load-balanced (queue group) +jobs.*._all — broadcast +jobs.*.label.group.web — prefix: role level +jobs.*.label.group.web.dev — prefix: role+env level +jobs.*.label.group.web.dev.us-east — prefix: exact match +``` + +Targeting examples: + +```bash +--target group:web # all web servers +--target group:web.dev # all web servers in dev +--target group:web.dev.us-east # exact match +``` + +The dimension order in the label value determines the targeting hierarchy. Place +the most commonly targeted broad dimension first (e.g., role before env before +region). Label subscriptions have **no queue group** — all matching workers +receive the message (broadcast within the label group). Label keys must match +`[a-zA-Z0-9_-]+`, and each dot-separated segment of the value must match the +same pattern. ## Supported Operations @@ -235,11 +261,8 @@ osapi client job add --json-file dns-query.json --target-hostname _any ### 2. Job States -``` -submitted → acknowledged → started → completed - ↓ - failed -``` +**State flow:** `submitted` → `acknowledged` → `started` → `completed` (or +`failed`) **State Transitions via Events:** @@ -373,32 +396,17 @@ The append-only architecture enables true broadcast job processing: ### Worker Subscription Patterns -**Load-Balanced Workers (Queue Groups):** - -```go -// DNS specialist workers -consumer.Subscribe("jobs.*._any.network.dns.>", - nats.Queue("dns-workers")) +Each worker creates JetStream consumers with these filter subjects: -// System monitoring workers -consumer.Subscribe("jobs.*._any.system.>", - nats.Queue("system-workers")) -``` - -**Direct Host Workers:** - -```go -// Worker on specific host -hostname, _ := os.Hostname() -consumer.Subscribe(fmt.Sprintf("jobs.*.%s.>", hostname)) -``` - -**Broadcast Workers (No Queue Groups):** - -```go -// All workers receive urgent notifications -consumer.Subscribe("jobs.*._all.>") -``` +- **Load-balanced** (queue group): `jobs.query._any`, `jobs.modify._any` — only + one worker in the queue group processes each message +- **Direct**: `jobs.query.host.{hostname}`, `jobs.modify.host.{hostname}` — + messages addressed to this specific worker +- **Broadcast**: `jobs.query._all`, `jobs.modify._all` — all workers receive the + message (no queue group) +- **Label** (per prefix level, no queue group): + `jobs.query.label.{key}.{prefix}`, `jobs.modify.label.{key}.{prefix}` — all + workers matching the label prefix receive the message ### Provider Pattern @@ -557,17 +565,20 @@ osapi client job delete --job-id uuid-12345 internal/job/ ├── types.go # Core domain types (Request, Response, QueuedJob, │ # QueueStats, WorkerState, TimelineEvent) -├── subjects.go # Subject routing and pattern generation +├── subjects.go # Subject routing, target parsing, label validation +├── hostname.go # Local hostname resolution and caching ├── config.go # Configuration structures ├── client/ # High-level job operations for API embedding -│ ├── client.go # Job client with CreateJob, GetQueueStats, etc. +│ ├── client.go # Publish-and-wait/collect with KV + stream │ ├── query.go # Query operations (system status, hostname, etc.) -│ ├── modify.go # Modify operations (DNS updates, ping, etc.) +│ ├── modify.go # Modify operations (DNS updates) +│ ├── jobs.go # CreateJob, GetJobStatus, GetQueueStats │ ├── worker.go # WriteStatusEvent, WriteJobResponse │ └── types.go # Client-specific types and interfaces └── worker/ # Job processing and worker lifecycle ├── worker.go # Worker implementation and lifecycle management - ├── consumer.go # NATS message consumption + ├── server.go # Worker server (NATS connect, stream setup, run) + ├── consumer.go # JetStream consumer creation and subscription ├── handler.go # Job lifecycle handling with status events ├── processor.go # Provider dispatch and execution ├── factory.go # Worker creation diff --git a/docs/docs/sidebar/usage/cli/client/network/dns/get.md b/docs/docs/sidebar/usage/cli/client/network/dns/get.md index 01846c17..5f9a60fd 100644 --- a/docs/docs/sidebar/usage/cli/client/network/dns/get.md +++ b/docs/docs/sidebar/usage/cli/client/network/dns/get.md @@ -25,6 +25,12 @@ $ osapi client network dns get --interface-name eth0 --target _all ┗━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━┛ ``` +Target by label to query a group of servers: + +```bash +$ osapi client network dns get --interface-name eth0 --target group:web +``` + ## Flags | Flag | Description | Default | diff --git a/docs/docs/sidebar/usage/cli/client/network/dns/update.md b/docs/docs/sidebar/usage/cli/client/network/dns/update.md index 45123257..509d3647 100644 --- a/docs/docs/sidebar/usage/cli/client/network/dns/update.md +++ b/docs/docs/sidebar/usage/cli/client/network/dns/update.md @@ -33,6 +33,15 @@ This will modify DNS on ALL hosts. Continue? [y/N] y ┗━━━━━━━━━━┻━━━━━━━━┻━━━━━━━━━━━┛ ``` +Target by label to update a group of servers: + +```bash +$ osapi client network dns update \ + --servers "1.1.1.1,2.2.2.2" \ + --interface-name eth0 \ + --target group:web +``` + ## Flags | Flag | Description | Default | diff --git a/docs/docs/sidebar/usage/cli/client/network/ping/ping.md b/docs/docs/sidebar/usage/cli/client/network/ping/ping.md index 94ebd716..3d7ccd1b 100644 --- a/docs/docs/sidebar/usage/cli/client/network/ping/ping.md +++ b/docs/docs/sidebar/usage/cli/client/network/ping/ping.md @@ -29,6 +29,12 @@ $ osapi client network ping --address 8.8.8.8 --target _all ┗━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┻━━━━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━━━━┛ ``` +Target by label to ping from a group of servers: + +```bash +$ osapi client network ping --address 8.8.8.8 --target group:web +``` + ## Flags | Flag | Description | Default | diff --git a/docs/docs/sidebar/usage/cli/client/system/hostname.md b/docs/docs/sidebar/usage/cli/client/system/hostname.md index 2d5ff861..ec4e0936 100644 --- a/docs/docs/sidebar/usage/cli/client/system/hostname.md +++ b/docs/docs/sidebar/usage/cli/client/system/hostname.md @@ -24,3 +24,9 @@ $ osapi client system hostname --target _all ┃ server2 ┃ ┗━━━━━━━━━━┛ ``` + +Target by label to query a group of servers: + +```bash +$ osapi client system hostname --target group:web +``` diff --git a/docs/docs/sidebar/usage/cli/client/system/status.md b/docs/docs/sidebar/usage/cli/client/system/status.md index 085b0d01..f21a184f 100644 --- a/docs/docs/sidebar/usage/cli/client/system/status.md +++ b/docs/docs/sidebar/usage/cli/client/system/status.md @@ -33,3 +33,10 @@ $ osapi client system status --target _all ┃ server2 ┃ 12 days, 3 hours, 45 minutes ┃ 0.45 ┃ 8 GB / 16 GB ┃ ┗━━━━━━━━━━┻━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┻━━━━━━━━━━━┻━━━━━━━━━━━━━━━┛ ``` + +Target by label to query a group of servers: + +```bash +$ osapi client system status --target group:web +$ osapi client system status --target group:web.dev +``` diff --git a/internal/api/network/gen/api.yaml b/internal/api/network/gen/api.yaml index 40dd899a..db1914ed 100644 --- a/internal/api/network/gen/api.yaml +++ b/internal/api/network/gen/api.yaml @@ -49,7 +49,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). requestBody: description: The server to ping. required: true @@ -126,7 +127,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': @@ -178,7 +180,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). requestBody: required: true content: diff --git a/internal/api/network/gen/network.gen.go b/internal/api/network/gen/network.gen.go index 46c2c715..7d1354b9 100644 --- a/internal/api/network/gen/network.gen.go +++ b/internal/api/network/gen/network.gen.go @@ -103,13 +103,13 @@ type PingResponse struct { // PutNetworkDNSParams defines parameters for PutNetworkDNS. type PutNetworkDNSParams struct { - // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + // TargetHostname Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). 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 Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` } @@ -121,7 +121,7 @@ type PostNetworkPingJSONBody struct { // PostNetworkPingParams defines parameters for PostNetworkPing. type PostNetworkPingParams struct { - // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + // TargetHostname Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` } diff --git a/internal/api/network/network_dns_get_by_interface.go b/internal/api/network/network_dns_get_by_interface.go index 3aa58e4c..1967d7d3 100644 --- a/internal/api/network/network_dns_get_by_interface.go +++ b/internal/api/network/network_dns_get_by_interface.go @@ -54,8 +54,8 @@ func (n Network) GetNetworkDNSByInterface( hostname = *request.Params.TargetHostname } - if hostname == job.BroadcastHost { - return n.getNetworkDNSAll(ctx, request.InterfaceName) + if job.IsBroadcastTarget(hostname) { + return n.getNetworkDNSBroadcast(ctx, hostname, request.InterfaceName) } dnsConfig, workerHostname, err := n.JobClient.QueryNetworkDNS( @@ -84,12 +84,13 @@ func (n Network) GetNetworkDNSByInterface( }, nil } -// getNetworkDNSAll handles _all broadcast for DNS config. -func (n Network) getNetworkDNSAll( +// getNetworkDNSBroadcast handles broadcast targets (_all or label) for DNS config. +func (n Network) getNetworkDNSBroadcast( ctx context.Context, + target string, iface string, ) (gen.GetNetworkDNSByInterfaceResponseObject, error) { - results, err := n.JobClient.QueryNetworkDNSAll(ctx, iface) + results, err := n.JobClient.QueryNetworkDNSBroadcast(ctx, target, iface) if err != nil { errMsg := err.Error() return gen.GetNetworkDNSByInterface500JSONResponse{ 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 8bea46f3..6c8b88be 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 @@ -102,7 +102,7 @@ func (suite *NetworkDNSGetByInterfaceIntegrationTestSuite) TestGetNetworkDNSByIn setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QueryNetworkDNSAll(gomock.Any(), "eth0"). + QueryNetworkDNSBroadcast(gomock.Any(), gomock.Any(), "eth0"). Return(map[string]*dns.Config{ "server1": { DNSServers: []string{"8.8.8.8"}, 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 79143dff..c720ef72 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 @@ -142,7 +142,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, setupMock: func() { s.mockJobClient.EXPECT(). - QueryNetworkDNSAll(gomock.Any(), "eth0"). + QueryNetworkDNSBroadcast(gomock.Any(), gomock.Any(), "eth0"). Return(map[string]*dns.Config{ "server1": { DNSServers: []string{"8.8.8.8"}, @@ -162,7 +162,7 @@ func (s *NetworkDNSGetByInterfacePublicTestSuite) TestGetNetworkDNSByInterface() }, setupMock: func() { s.mockJobClient.EXPECT(). - QueryNetworkDNSAll(gomock.Any(), "eth0"). + QueryNetworkDNSBroadcast(gomock.Any(), gomock.Any(), "eth0"). Return(nil, assert.AnError) }, validateFunc: func(resp gen.GetNetworkDNSByInterfaceResponseObject) { diff --git a/internal/api/network/network_dns_put_by_interface.go b/internal/api/network/network_dns_put_by_interface.go index c36d714c..f3a7ed64 100644 --- a/internal/api/network/network_dns_put_by_interface.go +++ b/internal/api/network/network_dns_put_by_interface.go @@ -66,8 +66,8 @@ func (n Network) PutNetworkDNS( hostname = *request.Params.TargetHostname } - if hostname == job.BroadcastHost { - return n.putNetworkDNSAll(ctx, servers, searchDomains, interfaceName) + if job.IsBroadcastTarget(hostname) { + return n.putNetworkDNSBroadcast(ctx, hostname, servers, searchDomains, interfaceName) } workerHostname, err := n.JobClient.ModifyNetworkDNS( @@ -94,14 +94,21 @@ func (n Network) PutNetworkDNS( }, nil } -// putNetworkDNSAll handles _all broadcast for DNS modification. -func (n Network) putNetworkDNSAll( +// putNetworkDNSBroadcast handles broadcast targets (_all or label) for DNS modification. +func (n Network) putNetworkDNSBroadcast( ctx context.Context, + target string, servers []string, searchDomains []string, interfaceName string, ) (gen.PutNetworkDNSResponseObject, error) { - results, err := n.JobClient.ModifyNetworkDNSAll(ctx, servers, searchDomains, interfaceName) + results, err := n.JobClient.ModifyNetworkDNSBroadcast( + ctx, + target, + servers, + searchDomains, + interfaceName, + ) if err != nil { errMsg := err.Error() return gen.PutNetworkDNS500JSONResponse{ 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 03f03576..0d6f764d 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 @@ -127,7 +127,7 @@ func (suite *NetworkDNSPutByInterfaceIntegrationTestSuite) TestPutNetworkDNS() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNSBroadcast(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(map[string]error{ "server1": nil, }, nil) 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 d032d439..ef582e1d 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 @@ -153,7 +153,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { }, setupMock: func() { s.mockJobClient.EXPECT(). - ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNSBroadcast(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(map[string]error{ "server1": nil, "server2": nil, @@ -175,7 +175,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { }, setupMock: func() { s.mockJobClient.EXPECT(). - ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNSBroadcast(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(map[string]error{ "server1": nil, "server2": fmt.Errorf("disk full"), @@ -197,7 +197,7 @@ func (s *NetworkDNSPutByInterfacePublicTestSuite) TestPutNetworkDNS() { }, setupMock: func() { s.mockJobClient.EXPECT(). - ModifyNetworkDNSAll(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). + ModifyNetworkDNSBroadcast(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, assert.AnError) }, validateFunc: func(resp gen.PutNetworkDNSResponseObject) { diff --git a/internal/api/network/network_ping_post.go b/internal/api/network/network_ping_post.go index 42092a4d..74cc0da0 100644 --- a/internal/api/network/network_ping_post.go +++ b/internal/api/network/network_ping_post.go @@ -56,8 +56,8 @@ func (n Network) PostNetworkPing( hostname = *request.Params.TargetHostname } - if hostname == job.BroadcastHost { - return n.postNetworkPingAll(ctx, request.Body.Address) + if job.IsBroadcastTarget(hostname) { + return n.postNetworkPingBroadcast(ctx, hostname, request.Body.Address) } pingResult, workerHostname, err := n.JobClient.QueryNetworkPing( @@ -79,12 +79,13 @@ func (n Network) PostNetworkPing( }, nil } -// postNetworkPingAll handles _all broadcast for ping. -func (n Network) postNetworkPingAll( +// postNetworkPingBroadcast handles broadcast targets (_all or label) for ping. +func (n Network) postNetworkPingBroadcast( ctx context.Context, + target string, address string, ) (gen.PostNetworkPingResponseObject, error) { - results, err := n.JobClient.QueryNetworkPingAll(ctx, address) + results, err := n.JobClient.QueryNetworkPingBroadcast(ctx, target, address) if err != nil { errMsg := err.Error() return gen.PostNetworkPing500JSONResponse{ diff --git a/internal/api/network/network_ping_post_integration_test.go b/internal/api/network/network_ping_post_integration_test.go index ea310dee..2202fd85 100644 --- a/internal/api/network/network_ping_post_integration_test.go +++ b/internal/api/network/network_ping_post_integration_test.go @@ -116,7 +116,7 @@ func (suite *NetworkPingPostIntegrationTestSuite) TestPostNetworkPing() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + QueryNetworkPingBroadcast(gomock.Any(), gomock.Any(), "1.1.1.1"). Return(map[string]*ping.Result{ "server1": { PacketsSent: 3, diff --git a/internal/api/network/network_ping_post_public_test.go b/internal/api/network/network_ping_post_public_test.go index cbed8511..20ce7e95 100644 --- a/internal/api/network/network_ping_post_public_test.go +++ b/internal/api/network/network_ping_post_public_test.go @@ -151,7 +151,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + QueryNetworkPingBroadcast(gomock.Any(), gomock.Any(), "1.1.1.1"). Return(map[string]*ping.Result{ "server1": { PacketsSent: 3, @@ -177,7 +177,7 @@ func (s *NetworkPingPostPublicTestSuite) TestPostNetworkPing() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QueryNetworkPingAll(gomock.Any(), "1.1.1.1"). + QueryNetworkPingBroadcast(gomock.Any(), gomock.Any(), "1.1.1.1"). Return(nil, assert.AnError) }, validateFunc: func(resp gen.PostNetworkPingResponseObject) { diff --git a/internal/api/system/gen/api.yaml b/internal/api/system/gen/api.yaml index 42a11d4d..166aba9f 100644 --- a/internal/api/system/gen/api.yaml +++ b/internal/api/system/gen/api.yaml @@ -48,7 +48,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': description: A JSON object containing the system's status information. @@ -97,7 +98,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': description: A JSON object containing the system's hostname. diff --git a/internal/api/system/gen/system.gen.go b/internal/api/system/gen/system.gen.go index 06a86f34..917ed46d 100644 --- a/internal/api/system/gen/system.gen.go +++ b/internal/api/system/gen/system.gen.go @@ -112,13 +112,13 @@ type SystemStatusResponse struct { // GetSystemHostnameParams defines parameters for GetSystemHostname. type GetSystemHostnameParams struct { - // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + // TargetHostname Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). 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 Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` } diff --git a/internal/api/system/system_hostname_get.go b/internal/api/system/system_hostname_get.go index 55d3c862..72ebe84b 100644 --- a/internal/api/system/system_hostname_get.go +++ b/internal/api/system/system_hostname_get.go @@ -47,8 +47,8 @@ func (s *System) GetSystemHostname( hostname = *request.Params.TargetHostname } - if hostname == job.BroadcastHost { - return s.getSystemHostnameAll(ctx) + if job.IsBroadcastTarget(hostname) { + return s.getSystemHostnameBroadcast(ctx, hostname) } result, workerHostname, err := s.JobClient.QuerySystemHostname(ctx, hostname) @@ -71,11 +71,12 @@ func (s *System) GetSystemHostname( }, nil } -// getSystemHostnameAll handles _all broadcast for system hostname. -func (s *System) getSystemHostnameAll( +// getSystemHostnameBroadcast handles broadcast targets (_all or label) for system hostname. +func (s *System) getSystemHostnameBroadcast( ctx context.Context, + target string, ) (gen.GetSystemHostnameResponseObject, error) { - results, err := s.JobClient.QuerySystemHostnameAll(ctx) + results, err := s.JobClient.QuerySystemHostnameBroadcast(ctx, target) if err != nil { errMsg := err.Error() return gen.GetSystemHostname500JSONResponse{ diff --git a/internal/api/system/system_hostname_get_integration_test.go b/internal/api/system/system_hostname_get_integration_test.go index d1c2f290..6b248da2 100644 --- a/internal/api/system/system_hostname_get_integration_test.go +++ b/internal/api/system/system_hostname_get_integration_test.go @@ -99,7 +99,7 @@ func (suite *SystemHostnameGetIntegrationTestSuite) TestGetSystemHostname() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QuerySystemHostnameAll(gomock.Any()). + QuerySystemHostnameBroadcast(gomock.Any(), gomock.Any()). Return(map[string]string{ "server1": "host1", "server2": "host2", diff --git a/internal/api/system/system_hostname_get_public_test.go b/internal/api/system/system_hostname_get_public_test.go index 5d02073f..c7ffd68b 100644 --- a/internal/api/system/system_hostname_get_public_test.go +++ b/internal/api/system/system_hostname_get_public_test.go @@ -75,6 +75,21 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { s.Equal("my-hostname", r.Results[0].Hostname) }, }, + { + name: "empty hostname falls back to worker hostname", + request: gen.GetSystemHostnameRequestObject{}, + setupMock: func() { + s.mockJobClient.EXPECT(). + QuerySystemHostname(gomock.Any(), gomock.Any()). + Return("", "worker1", nil) + }, + validateFunc: func(resp gen.GetSystemHostnameResponseObject) { + r, ok := resp.(gen.GetSystemHostname200JSONResponse) + s.True(ok) + s.Require().Len(r.Results, 1) + s.Equal("worker1", r.Results[0].Hostname) + }, + }, { name: "validation error empty target_hostname", request: gen.GetSystemHostnameRequestObject{ @@ -109,7 +124,7 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QuerySystemHostnameAll(gomock.Any()). + QuerySystemHostnameBroadcast(gomock.Any(), gomock.Any()). Return(map[string]string{ "server1": "host1", "server2": "host2", @@ -126,7 +141,7 @@ func (s *SystemHostnameGetPublicTestSuite) TestGetSystemHostname() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QuerySystemHostnameAll(gomock.Any()). + QuerySystemHostnameBroadcast(gomock.Any(), gomock.Any()). Return(nil, assert.AnError) }, validateFunc: func(resp gen.GetSystemHostnameResponseObject) { diff --git a/internal/api/system/system_status_get.go b/internal/api/system/system_status_get.go index 8220ce76..2e44f050 100644 --- a/internal/api/system/system_status_get.go +++ b/internal/api/system/system_status_get.go @@ -49,8 +49,8 @@ func (s *System) GetSystemStatus( hostname = *request.Params.TargetHostname } - if hostname == job.BroadcastHost { - return s.getSystemStatusAll(ctx) + if job.IsBroadcastTarget(hostname) { + return s.getSystemStatusBroadcast(ctx, hostname) } status, err := s.JobClient.QuerySystemStatus(ctx, hostname) @@ -68,11 +68,12 @@ func (s *System) GetSystemStatus( }, nil } -// getSystemStatusAll handles _all broadcast for system status. -func (s *System) getSystemStatusAll( +// getSystemStatusBroadcast handles broadcast targets (_all or label) for system status. +func (s *System) getSystemStatusBroadcast( ctx context.Context, + target string, ) (gen.GetSystemStatusResponseObject, error) { - results, err := s.JobClient.QuerySystemStatusAll(ctx) + results, err := s.JobClient.QuerySystemStatusBroadcast(ctx, target) if err != nil { errMsg := err.Error() return gen.GetSystemStatus500JSONResponse{ diff --git a/internal/api/system/system_status_get_integration_test.go b/internal/api/system/system_status_get_integration_test.go index 645488bf..1babb031 100644 --- a/internal/api/system/system_status_get_integration_test.go +++ b/internal/api/system/system_status_get_integration_test.go @@ -160,7 +160,7 @@ func (suite *SystemStatusGetIntegrationTestSuite) TestGetSystemStatus() { setupJobMock: func() *jobmocks.MockJobClient { mock := jobmocks.NewMockJobClient(suite.ctrl) mock.EXPECT(). - QuerySystemStatusAll(gomock.Any()). + QuerySystemStatusBroadcast(gomock.Any(), gomock.Any()). Return([]*job.SystemStatusResponse{ { Hostname: "server1", diff --git a/internal/api/system/system_status_get_public_test.go b/internal/api/system/system_status_get_public_test.go index 1d957ff3..bb6661d8 100644 --- a/internal/api/system/system_status_get_public_test.go +++ b/internal/api/system/system_status_get_public_test.go @@ -112,7 +112,7 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QuerySystemStatusAll(gomock.Any()). + QuerySystemStatusBroadcast(gomock.Any(), gomock.Any()). Return([]*jobtypes.SystemStatusResponse{ {Hostname: "server1", Uptime: time.Hour}, {Hostname: "server2", Uptime: 2 * time.Hour}, @@ -129,7 +129,7 @@ func (s *SystemStatusGetPublicTestSuite) TestGetSystemStatus() { }, setupMock: func() { s.mockJobClient.EXPECT(). - QuerySystemStatusAll(gomock.Any()). + QuerySystemStatusBroadcast(gomock.Any(), gomock.Any()). Return(nil, assert.AnError) }, validateFunc: func(resp gen.GetSystemStatusResponseObject) { diff --git a/internal/client/gen/api.yaml b/internal/client/gen/api.yaml index ac453e4e..929691b7 100644 --- a/internal/client/gen/api.yaml +++ b/internal/client/gen/api.yaml @@ -341,7 +341,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). requestBody: description: The server to ping. required: true @@ -422,7 +423,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': description: List of DNS servers. @@ -473,7 +475,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). requestBody: required: true content: @@ -531,7 +534,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': description: A JSON object containing the system's status information. @@ -575,7 +579,8 @@ paths: type: string default: _any description: > - Target hostname for routing (_any, _all, or specific hostname). + Target: _any (load-balanced), _all (broadcast), hostname + (direct), or key:value (label group, e.g., group:web.dev). responses: '200': description: A JSON object containing the system's hostname. diff --git a/internal/client/gen/client.gen.go b/internal/client/gen/client.gen.go index 153d50f2..c7b7e243 100644 --- a/internal/client/gen/client.gen.go +++ b/internal/client/gen/client.gen.go @@ -310,13 +310,13 @@ type GetJobParams struct { // PutNetworkDNSParams defines parameters for PutNetworkDNS. type PutNetworkDNSParams struct { - // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + // TargetHostname Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). 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 Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` } @@ -328,19 +328,19 @@ type PostNetworkPingJSONBody struct { // PostNetworkPingParams defines parameters for PostNetworkPing. type PostNetworkPingParams struct { - // TargetHostname Target hostname for routing (_any, _all, or specific hostname). + // TargetHostname Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). 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 Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). 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 Target: _any (load-balanced), _all (broadcast), hostname (direct), or key:value (label group, e.g., group:web.dev). TargetHostname *string `form:"target_hostname,omitempty" json:"target_hostname,omitempty"` } diff --git a/internal/config/types.go b/internal/config/types.go index 97aa5986..75cda275 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -164,4 +164,6 @@ type JobWorker struct { Hostname string `mapstructure:"hostname"` // MaxJobs maximum number of concurrent jobs to process. MaxJobs int `mapstructure:"max_jobs"` + // Labels are key-value pairs for label-based routing (e.g., role: web, env: prod). + Labels map[string]string `mapstructure:"labels"` } diff --git a/internal/job/client/client.go b/internal/job/client/client.go index 34dd2630..46e607b9 100644 --- a/internal/job/client/client.go +++ b/internal/job/client/client.go @@ -37,16 +37,20 @@ import ( // Client provides methods for publishing job requests and retrieving responses. type Client struct { - logger *slog.Logger - natsClient messaging.NATSClient - kv nats.KeyValue - timeout time.Duration + logger *slog.Logger + natsClient messaging.NATSClient + kv nats.KeyValue + timeout time.Duration + broadcastQuietTime time.Duration } // Options configures the jobs client. type Options struct { // Timeout for waiting for job responses (default: 30s) Timeout time.Duration + // BroadcastQuietPeriod is the silence window after the last broadcast + // response before collection stops (default: 3s) + BroadcastQuietPeriod time.Duration // KVBucket for job storage (required) KVBucket nats.KeyValue } @@ -64,11 +68,17 @@ func New( return nil, fmt.Errorf("KVBucket cannot be nil") } + quietPeriod := opts.BroadcastQuietPeriod + if quietPeriod == 0 { + quietPeriod = broadcastQuietPeriod + } + return &Client{ - logger: logger, - natsClient: natsClient, - kv: opts.KVBucket, - timeout: opts.Timeout, + logger: logger, + natsClient: natsClient, + kv: opts.KVBucket, + timeout: opts.Timeout, + broadcastQuietTime: quietPeriod, }, nil } @@ -239,19 +249,7 @@ func (c *Client) publishAndCollect( for { select { case <-timeoutCtx.Done(): - if len(responses) == 0 { - return nil, fmt.Errorf( - "timeout waiting for broadcast responses: no workers responded", - ) - } - 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 @@ -281,7 +279,16 @@ func (c *Client) publishAndCollect( // Reset quiet period — if no more responses arrive within // this window, we're done collecting. - quietTimer.Reset(broadcastQuietPeriod) + quietTimer.Reset(c.broadcastQuietTime) + continue + } + + // Reached from either timeoutCtx.Done() or quietTimer.C + if len(responses) == 0 { + return nil, fmt.Errorf( + "timeout waiting for broadcast responses: no workers responded", + ) } + return responses, nil } } diff --git a/internal/job/client/modify.go b/internal/job/client/modify.go index a3b3cc89..3faeab8e 100644 --- a/internal/job/client/modify.go +++ b/internal/job/client/modify.go @@ -48,7 +48,7 @@ func (c *Client) ModifyNetworkDNS( Data: json.RawMessage(data), } - subject := job.BuildModifySubject(hostname) + subject := job.BuildSubjectFromTarget(job.JobsModifyPrefix, hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { return "", fmt.Errorf("failed to publish and wait: %w", err) @@ -71,9 +71,11 @@ func (c *Client) ModifyNetworkDNSAny( return c.ModifyNetworkDNS(ctx, job.AnyHost, servers, searchDomains, iface) } -// ModifyNetworkDNSAll modifies DNS configuration on all hosts. -func (c *Client) ModifyNetworkDNSAll( +// ModifyNetworkDNSBroadcast modifies DNS configuration on a broadcast target +// (_all or a label target like role:web). +func (c *Client) ModifyNetworkDNSBroadcast( ctx context.Context, + target string, servers []string, searchDomains []string, iface string, @@ -90,7 +92,7 @@ func (c *Client) ModifyNetworkDNSAll( Data: json.RawMessage(data), } - subject := job.BuildModifySubject(job.BroadcastHost) + subject := job.BuildSubjectFromTarget(job.JobsModifyPrefix, target) responses, err := c.publishAndCollect(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) @@ -107,3 +109,13 @@ func (c *Client) ModifyNetworkDNSAll( return results, nil } + +// ModifyNetworkDNSAll modifies DNS configuration on all hosts. +func (c *Client) ModifyNetworkDNSAll( + ctx context.Context, + servers []string, + searchDomains []string, + iface string, +) (map[string]error, error) { + return c.ModifyNetworkDNSBroadcast(ctx, job.BroadcastHost, servers, searchDomains, iface) +} diff --git a/internal/job/client/modify_public_test.go b/internal/job/client/modify_public_test.go index 3d8bddb2..fb656fff 100644 --- a/internal/job/client/modify_public_test.go +++ b/internal/job/client/modify_public_test.go @@ -119,7 +119,7 @@ func (s *ModifyPublicTestSuite) TestModifyNetworkDNS() { s.mockCtrl, s.mockKV, s.mockNATSClient, - "jobs.modify.server1", + "jobs.modify.host.server1", tt.responseData, tt.mockError, ) diff --git a/internal/job/client/query.go b/internal/job/client/query.go index 14863d8e..0ae91958 100644 --- a/internal/job/client/query.go +++ b/internal/job/client/query.go @@ -42,7 +42,7 @@ func (c *Client) QuerySystemStatus( Data: json.RawMessage(`{}`), } - subject := job.BuildQuerySubject(hostname) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to publish and wait: %w", err) @@ -72,7 +72,7 @@ func (c *Client) QuerySystemHostname( Data: json.RawMessage(`{}`), } - subject := job.BuildQuerySubject(hostname) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { return "", "", fmt.Errorf("failed to publish and wait: %w", err) @@ -108,7 +108,7 @@ func (c *Client) QueryNetworkDNS( Data: json.RawMessage(data), } - subject := job.BuildQuerySubject(hostname) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { return nil, "", fmt.Errorf("failed to publish and wait: %w", err) @@ -133,9 +133,11 @@ func (c *Client) QuerySystemStatusAny( return c.QuerySystemStatus(ctx, job.AnyHost) } -// QuerySystemStatusAll queries system status from all hosts. -func (c *Client) QuerySystemStatusAll( +// QuerySystemStatusBroadcast queries system status from a broadcast target +// (_all or a label target like role:web). +func (c *Client) QuerySystemStatusBroadcast( ctx context.Context, + target string, ) ([]*job.SystemStatusResponse, error) { req := &job.Request{ Type: job.TypeQuery, @@ -144,7 +146,7 @@ func (c *Client) QuerySystemStatusAll( Data: json.RawMessage(`{}`), } - subject := job.BuildQuerySubject(job.BroadcastHost) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, target) responses, err := c.publishAndCollect(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) @@ -171,6 +173,13 @@ func (c *Client) QuerySystemStatusAll( return results, nil } +// QuerySystemStatusAll queries system status from all hosts. +func (c *Client) QuerySystemStatusAll( + ctx context.Context, +) ([]*job.SystemStatusResponse, error) { + return c.QuerySystemStatusBroadcast(ctx, job.BroadcastHost) +} + // QueryNetworkPing pings a host from a specific hostname. func (c *Client) QueryNetworkPing( ctx context.Context, @@ -187,7 +196,7 @@ func (c *Client) QueryNetworkPing( Data: json.RawMessage(data), } - subject := job.BuildQuerySubject(hostname) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, hostname) resp, err := c.publishAndWait(ctx, subject, req) if err != nil { return nil, "", fmt.Errorf("failed to publish and wait: %w", err) @@ -213,9 +222,11 @@ func (c *Client) QueryNetworkPingAny( return c.QueryNetworkPing(ctx, job.AnyHost, address) } -// QuerySystemHostnameAll queries hostname from all hosts. -func (c *Client) QuerySystemHostnameAll( +// QuerySystemHostnameBroadcast queries hostname from a broadcast target +// (_all or a label target like role:web). +func (c *Client) QuerySystemHostnameBroadcast( ctx context.Context, + target string, ) (map[string]string, error) { req := &job.Request{ Type: job.TypeQuery, @@ -224,7 +235,7 @@ func (c *Client) QuerySystemHostnameAll( Data: json.RawMessage(`{}`), } - subject := job.BuildQuerySubject(job.BroadcastHost) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, target) responses, err := c.publishAndCollect(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) @@ -249,9 +260,18 @@ func (c *Client) QuerySystemHostnameAll( return results, nil } -// QueryNetworkDNSAll queries DNS configuration from all hosts. -func (c *Client) QueryNetworkDNSAll( +// QuerySystemHostnameAll queries hostname from all hosts. +func (c *Client) QuerySystemHostnameAll( ctx context.Context, +) (map[string]string, error) { + return c.QuerySystemHostnameBroadcast(ctx, job.BroadcastHost) +} + +// QueryNetworkDNSBroadcast queries DNS configuration from a broadcast target +// (_all or a label target like role:web). +func (c *Client) QueryNetworkDNSBroadcast( + ctx context.Context, + target string, iface string, ) (map[string]*dns.Config, error) { data, _ := json.Marshal(map[string]interface{}{ @@ -264,7 +284,7 @@ func (c *Client) QueryNetworkDNSAll( Data: json.RawMessage(data), } - subject := job.BuildQuerySubject(job.BroadcastHost) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, target) responses, err := c.publishAndCollect(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) @@ -287,9 +307,19 @@ func (c *Client) QueryNetworkDNSAll( return results, nil } -// QueryNetworkPingAll pings a host from all hosts. -func (c *Client) QueryNetworkPingAll( +// QueryNetworkDNSAll queries DNS configuration from all hosts. +func (c *Client) QueryNetworkDNSAll( ctx context.Context, + iface string, +) (map[string]*dns.Config, error) { + return c.QueryNetworkDNSBroadcast(ctx, job.BroadcastHost, iface) +} + +// QueryNetworkPingBroadcast pings a host from a broadcast target +// (_all or a label target like role:web). +func (c *Client) QueryNetworkPingBroadcast( + ctx context.Context, + target string, address string, ) (map[string]*ping.Result, error) { data, _ := json.Marshal(map[string]interface{}{ @@ -302,7 +332,7 @@ func (c *Client) QueryNetworkPingAll( Data: json.RawMessage(data), } - subject := job.BuildQuerySubject(job.BroadcastHost) + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, target) responses, err := c.publishAndCollect(ctx, subject, req) if err != nil { return nil, fmt.Errorf("failed to collect broadcast responses: %w", err) @@ -325,20 +355,49 @@ func (c *Client) QueryNetworkPingAll( return results, nil } +// QueryNetworkPingAll pings a host from all hosts. +func (c *Client) QueryNetworkPingAll( + ctx context.Context, + address string, +) (map[string]*ping.Result, error) { + return c.QueryNetworkPingBroadcast(ctx, job.BroadcastHost, address) +} + // 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) + 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 discover workers: %w", err) } - workers := make([]job.WorkerInfo, 0, len(hostnames)) - for _, hostname := range hostnames { + workers := make([]job.WorkerInfo, 0, len(responses)) + for _, resp := range responses { + if resp.Status == "failed" { + continue + } + + var result struct { + Hostname string `json:"hostname"` + Labels map[string]string `json:"labels,omitempty"` + } + if err := json.Unmarshal(resp.Data, &result); err != nil { + continue + } + workers = append(workers, job.WorkerInfo{ - Hostname: hostname, + Hostname: result.Hostname, + Labels: result.Labels, }) } diff --git a/internal/job/client/query_public_test.go b/internal/job/client/query_public_test.go index b41b6025..5b2beabc 100644 --- a/internal/job/client/query_public_test.go +++ b/internal/job/client/query_public_test.go @@ -30,6 +30,7 @@ import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/suite" + "github.com/retr0h/osapi/internal/job" "github.com/retr0h/osapi/internal/job/client" jobmocks "github.com/retr0h/osapi/internal/job/mocks" ) @@ -148,7 +149,7 @@ func (s *QueryPublicTestSuite) TestQuerySystemStatus() { for _, tt := range tests { s.Run(tt.name, func() { - subject := "jobs.query." + tt.hostname + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, tt.hostname) setupPublishAndWaitMocks( s.mockCtrl, @@ -232,7 +233,7 @@ func (s *QueryPublicTestSuite) TestQuerySystemHostname() { for _, tt := range tests { s.Run(tt.name, func() { - subject := "jobs.query." + tt.hostname + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, tt.hostname) setupPublishAndWaitMocks( s.mockCtrl, @@ -351,7 +352,7 @@ func (s *QueryPublicTestSuite) TestQueryNetworkDNS() { for _, tt := range tests { s.Run(tt.name, func() { - subject := "jobs.query." + tt.hostname + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, tt.hostname) setupPublishAndWaitMocks( s.mockCtrl, @@ -439,7 +440,7 @@ func (s *QueryPublicTestSuite) TestQueryNetworkPing() { for _, tt := range tests { s.Run(tt.name, func() { - subject := "jobs.query." + tt.hostname + subject := job.BuildSubjectFromTarget(job.JobsQueryPrefix, tt.hostname) setupPublishAndWaitMocks( s.mockCtrl, @@ -642,7 +643,7 @@ func (s *QueryPublicTestSuite) TestPublishAndWaitErrorPaths() { s.mockCtrl, s.mockKV, s.mockNATSClient, - "jobs.query.server1", + "jobs.query.host.server1", tt.opts, ) @@ -1135,6 +1136,28 @@ func (s *QueryPublicTestSuite) TestListWorkers() { expectError: true, errorContains: "failed to discover workers", }, + { + name: "failed workers skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"worker1"}}`, + `{"status":"failed","hostname":"server2","error":"crash"}`, + }, + }, + expectedCount: 1, + }, + { + name: "unmarshal error in worker data skipped", + timeout: 50 * time.Millisecond, + opts: &publishAndCollectMockOpts{ + responseEntries: []string{ + `{"status":"completed","hostname":"server1","data":{"hostname":"worker1"}}`, + `{"status":"completed","hostname":"server2","data":"not_an_object"}`, + }, + }, + expectedCount: 1, + }, } for _, tt := range tests { diff --git a/internal/job/client/types.go b/internal/job/client/types.go index 9be3fb6a..059f9a64 100644 --- a/internal/job/client/types.go +++ b/internal/job/client/types.go @@ -62,6 +62,10 @@ type JobClient interface { QuerySystemStatusAll( ctx context.Context, ) ([]*job.SystemStatusResponse, error) + QuerySystemStatusBroadcast( + ctx context.Context, + target string, + ) ([]*job.SystemStatusResponse, error) QuerySystemHostname( ctx context.Context, hostname string, @@ -69,6 +73,10 @@ type JobClient interface { QuerySystemHostnameAll( ctx context.Context, ) (map[string]string, error) + QuerySystemHostnameBroadcast( + ctx context.Context, + target string, + ) (map[string]string, error) QueryNetworkDNS( ctx context.Context, hostname string, @@ -78,6 +86,11 @@ type JobClient interface { ctx context.Context, iface string, ) (map[string]*dns.Config, error) + QueryNetworkDNSBroadcast( + ctx context.Context, + target string, + iface string, + ) (map[string]*dns.Config, error) // Modify operations ModifyNetworkDNS( @@ -99,6 +112,13 @@ type JobClient interface { searchDomains []string, iface string, ) (map[string]error, error) + ModifyNetworkDNSBroadcast( + ctx context.Context, + target string, + servers []string, + searchDomains []string, + iface string, + ) (map[string]error, error) QueryNetworkPing( ctx context.Context, hostname string, @@ -112,6 +132,11 @@ type JobClient interface { ctx context.Context, address string, ) (map[string]*ping.Result, error) + QueryNetworkPingBroadcast( + ctx context.Context, + target string, + address string, + ) (map[string]*ping.Result, error) // Worker discovery ListWorkers( diff --git a/internal/job/mocks/job_client.gen.go b/internal/job/mocks/job_client.gen.go index 35a82441..62fe19f3 100644 --- a/internal/job/mocks/job_client.gen.go +++ b/internal/job/mocks/job_client.gen.go @@ -217,6 +217,21 @@ func (mr *MockJobClientMockRecorder) ModifyNetworkDNSAny(arg0, arg1, arg2, arg3 return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ModifyNetworkDNSAny", reflect.TypeOf((*MockJobClient)(nil).ModifyNetworkDNSAny), arg0, arg1, arg2, arg3) } +// ModifyNetworkDNSBroadcast mocks base method. +func (m *MockJobClient) ModifyNetworkDNSBroadcast(arg0 context.Context, arg1 string, arg2, arg3 []string, arg4 string) (map[string]error, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ModifyNetworkDNSBroadcast", arg0, arg1, arg2, arg3, arg4) + ret0, _ := ret[0].(map[string]error) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ModifyNetworkDNSBroadcast indicates an expected call of ModifyNetworkDNSBroadcast. +func (mr *MockJobClientMockRecorder) ModifyNetworkDNSBroadcast(arg0, arg1, arg2, arg3, arg4 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ModifyNetworkDNSBroadcast", reflect.TypeOf((*MockJobClient)(nil).ModifyNetworkDNSBroadcast), arg0, arg1, arg2, arg3, arg4) +} + // QueryNetworkDNS mocks base method. func (m *MockJobClient) QueryNetworkDNS(arg0 context.Context, arg1, arg2 string) (*dns.Config, string, error) { m.ctrl.T.Helper() @@ -248,6 +263,21 @@ func (mr *MockJobClientMockRecorder) QueryNetworkDNSAll(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkDNSAll", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkDNSAll), arg0, arg1) } +// QueryNetworkDNSBroadcast mocks base method. +func (m *MockJobClient) QueryNetworkDNSBroadcast(arg0 context.Context, arg1, arg2 string) (map[string]*dns.Config, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryNetworkDNSBroadcast", arg0, arg1, arg2) + ret0, _ := ret[0].(map[string]*dns.Config) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryNetworkDNSBroadcast indicates an expected call of QueryNetworkDNSBroadcast. +func (mr *MockJobClientMockRecorder) QueryNetworkDNSBroadcast(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkDNSBroadcast", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkDNSBroadcast), arg0, arg1, arg2) +} + // QueryNetworkPing mocks base method. func (m *MockJobClient) QueryNetworkPing(arg0 context.Context, arg1, arg2 string) (*ping.Result, string, error) { m.ctrl.T.Helper() @@ -295,6 +325,21 @@ func (mr *MockJobClientMockRecorder) QueryNetworkPingAny(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkPingAny", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkPingAny), arg0, arg1) } +// QueryNetworkPingBroadcast mocks base method. +func (m *MockJobClient) QueryNetworkPingBroadcast(arg0 context.Context, arg1, arg2 string) (map[string]*ping.Result, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QueryNetworkPingBroadcast", arg0, arg1, arg2) + ret0, _ := ret[0].(map[string]*ping.Result) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QueryNetworkPingBroadcast indicates an expected call of QueryNetworkPingBroadcast. +func (mr *MockJobClientMockRecorder) QueryNetworkPingBroadcast(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QueryNetworkPingBroadcast", reflect.TypeOf((*MockJobClient)(nil).QueryNetworkPingBroadcast), arg0, arg1, arg2) +} + // QuerySystemHostname mocks base method. func (m *MockJobClient) QuerySystemHostname(arg0 context.Context, arg1 string) (string, string, error) { m.ctrl.T.Helper() @@ -326,6 +371,21 @@ func (mr *MockJobClientMockRecorder) QuerySystemHostnameAll(arg0 interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemHostnameAll", reflect.TypeOf((*MockJobClient)(nil).QuerySystemHostnameAll), arg0) } +// QuerySystemHostnameBroadcast mocks base method. +func (m *MockJobClient) QuerySystemHostnameBroadcast(arg0 context.Context, arg1 string) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QuerySystemHostnameBroadcast", arg0, arg1) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QuerySystemHostnameBroadcast indicates an expected call of QuerySystemHostnameBroadcast. +func (mr *MockJobClientMockRecorder) QuerySystemHostnameBroadcast(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemHostnameBroadcast", reflect.TypeOf((*MockJobClient)(nil).QuerySystemHostnameBroadcast), arg0, arg1) +} + // QuerySystemStatus mocks base method. func (m *MockJobClient) QuerySystemStatus(arg0 context.Context, arg1 string) (*job.SystemStatusResponse, error) { m.ctrl.T.Helper() @@ -371,6 +431,21 @@ func (mr *MockJobClientMockRecorder) QuerySystemStatusAny(arg0 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemStatusAny", reflect.TypeOf((*MockJobClient)(nil).QuerySystemStatusAny), arg0) } +// QuerySystemStatusBroadcast mocks base method. +func (m *MockJobClient) QuerySystemStatusBroadcast(arg0 context.Context, arg1 string) ([]*job.SystemStatusResponse, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "QuerySystemStatusBroadcast", arg0, arg1) + ret0, _ := ret[0].([]*job.SystemStatusResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// QuerySystemStatusBroadcast indicates an expected call of QuerySystemStatusBroadcast. +func (mr *MockJobClientMockRecorder) QuerySystemStatusBroadcast(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "QuerySystemStatusBroadcast", reflect.TypeOf((*MockJobClient)(nil).QuerySystemStatusBroadcast), arg0, arg1) +} + // WriteJobResponse mocks base method. func (m *MockJobClient) WriteJobResponse(arg0 context.Context, arg1, arg2 string, arg3 []byte, arg4, arg5 string) error { m.ctrl.T.Helper() diff --git a/internal/job/subjects.go b/internal/job/subjects.go index 0753fbe3..7b2cfd7e 100644 --- a/internal/job/subjects.go +++ b/internal/job/subjects.go @@ -20,17 +20,20 @@ // Package job provides NATS subject hierarchy for distributed job routing. // -// Subject Format: jobs.{type}.{hostname} +// Subject Format: jobs.{type}.{routing_type}.{value...} // // Routing Patterns: -// - Direct: jobs.query.server1 (specific host) +// - Direct: jobs.query.host.server1 (specific host) // - Any: jobs.query._any (load-balanced across available workers) // - Broadcast: jobs.modify._all (all workers receive) +// - Label: jobs.query.label.group.web (broadcast to label group) +// - Hierarchical: jobs.query.label.group.web.dev.us-east (prefix matching) // // Workers subscribe to: -// - Their specific hostname: jobs.*.server1 +// - Their specific hostname: jobs.*.host.server1 // - Load-balanced work: jobs.*._any (with queue group) // - Broadcast messages: jobs.*._all +// - Label prefixes: jobs.*.label.group.web, jobs.*.label.group.web.dev, etc. package job import ( @@ -101,32 +104,68 @@ func BuildModifySubjectForAllHosts() string { return BuildModifySubject(AllHosts) } -// ParseSubject extracts components from a job subject. -// Expected format: jobs.{type}.{hostname} +// ParseSubject extracts the prefix and routing target from a job subject. +// Supported formats: +// - jobs.{type}._any (3 parts) +// - jobs.{type}._all (3 parts) +// - jobs.{type}.host.{hostname} (4 parts) +// - jobs.{type}.label.{key}.{value...} (5+ parts, hierarchical values) func ParseSubject( subject string, ) (prefix, hostname string, err error) { parts := strings.Split(subject, ".") - if len(parts) != 3 { + if len(parts) < 3 { return "", "", fmt.Errorf("invalid subject format: %s", subject) } prefix = fmt.Sprintf("%s.%s", parts[0], parts[1]) - hostname = parts[2] + + switch { + case len(parts) == 3: + // _any, _all, or legacy hostname + hostname = parts[2] + case len(parts) == 4 && parts[2] == "host": + // jobs.{type}.host.{hostname} + hostname = parts[3] + case len(parts) >= 5 && parts[2] == "label": + // jobs.{type}.label.{key}.{value...} + // Value segments are joined back with dots for hierarchical labels + key := parts[3] + value := strings.Join(parts[4:], ".") + hostname = fmt.Sprintf("%s:%s", key, value) + default: + return "", "", fmt.Errorf("invalid subject format: %s", subject) + } return prefix, hostname, nil } // BuildWorkerSubscriptionPattern creates subscription patterns for workers. // Workers typically subscribe to their own hostname and special routing patterns. +// If labels are provided, hierarchical prefix subscriptions are included for +// each label. For example, a label "group: web.dev.us-east" generates subscriptions +// at every prefix level (group:web, group:web.dev, group:web.dev.us-east). func BuildWorkerSubscriptionPattern( hostname string, + labels map[string]string, ) []string { - return []string{ - fmt.Sprintf("jobs.*.%s", hostname), // Direct messages to this host + labelCount := 0 + for _, value := range labels { + labelCount += len(strings.Split(value, ".")) + } + + patterns := make([]string, 0, 3+labelCount) + patterns = append(patterns, + fmt.Sprintf("jobs.*.host.%s", hostname), // Direct messages to this host fmt.Sprintf("jobs.*.%s", AnyHost), // Load-balanced messages fmt.Sprintf("jobs.*.%s", BroadcastHost), // Broadcast messages + ) + + for key, value := range labels { + patterns = append(patterns, BuildLabelSubjects(key, value)...) } + + return patterns } // BuildWorkerQueueGroup returns the queue group name for load-balanced subscriptions. @@ -154,3 +193,94 @@ func SanitizeHostname( reg := regexp.MustCompile(`[^a-zA-Z0-9_]`) return reg.ReplaceAllString(hostname, "_") } + +// labelSegmentRegex validates that each segment of a label key or value is NATS subject-safe. +var labelSegmentRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// ValidateLabel checks that a label key and value are valid for use in NATS subjects. +// Keys must be a single segment matching [a-zA-Z0-9_-]+. +// Values may be hierarchical (dot-separated), where each segment matches [a-zA-Z0-9_-]+. +func ValidateLabel( + key, value string, +) error { + if !labelSegmentRegex.MatchString(key) { + return fmt.Errorf("invalid label key %q: must match [a-zA-Z0-9_-]+", key) + } + for _, segment := range strings.Split(value, ".") { + if !labelSegmentRegex.MatchString(segment) { + return fmt.Errorf( + "invalid label value segment %q in %q: each segment must match [a-zA-Z0-9_-]+", + segment, + value, + ) + } + } + return nil +} + +// ParseTarget parses a --target value into routing components. +// Returns routingType ("host", "label", AnyHost, or BroadcastHost), key, and value. +// Label values may contain dots for hierarchical targeting (e.g., "group:web.dev.us-east"). +func ParseTarget( + target string, +) (routingType, key, value string) { + switch { + case target == AnyHost || target == BroadcastHost: + return target, "", "" + case strings.Contains(target, ":"): + parts := strings.SplitN(target, ":", 2) + return "label", parts[0], parts[1] + default: + return "host", target, "" + } +} + +// BuildSubjectFromTarget builds the full NATS subject for any target value. +// For label targets with hierarchical values (e.g., "group:web.dev"), each dot-separated +// segment becomes a subject token: jobs.query.label.group.web.dev +func BuildSubjectFromTarget( + prefix, target string, +) string { + rt, key, value := ParseTarget(target) + switch rt { + case AnyHost, BroadcastHost: + return fmt.Sprintf("%s.%s", prefix, rt) + case "label": + return fmt.Sprintf("%s.label.%s.%s", prefix, key, value) + default: // "host" + return fmt.Sprintf("%s.host.%s", prefix, key) + } +} + +// IsBroadcastTarget returns true if the target requires publishAndCollect +// (broadcast) semantics: _all or any key:value label target. +func IsBroadcastTarget( + target string, +) bool { + if target == BroadcastHost { + return true + } + return strings.Contains(target, ":") +} + +// BuildLabelSubjects builds subscription subjects for a label with hierarchical +// prefix matching. For a label "group: web.dev.us-east", it returns subjects +// for every prefix level: +// +// jobs.*.label.group.web +// jobs.*.label.group.web.dev +// jobs.*.label.group.web.dev.us-east +// +// This enables targeting at any level of the hierarchy: --target group:web +// matches all workers whose group label starts with "web". +func BuildLabelSubjects( + key, value string, +) []string { + segments := strings.Split(value, ".") + subjects := make([]string, 0, len(segments)) + for i := range segments { + prefix := strings.Join(segments[:i+1], ".") + subjects = append(subjects, fmt.Sprintf("jobs.*.label.%s.%s", key, prefix)) + } + return subjects +} diff --git a/internal/job/subjects_public_test.go b/internal/job/subjects_public_test.go index 8aa5546b..46c9fa2e 100644 --- a/internal/job/subjects_public_test.go +++ b/internal/job/subjects_public_test.go @@ -172,8 +172,29 @@ func (suite *SubjectsPublicTestSuite) TestParseSubject() { wantErr: true, }, { - name: "when parsing invalid subject with too many parts", - subject: "jobs.query.server-01.extra.part", + name: "when parsing host subject", + subject: "jobs.query.host.server-01", + wantPrefix: "jobs.query", + wantHostname: "server-01", + wantErr: false, + }, + { + name: "when parsing label subject", + subject: "jobs.query.label.group.web", + wantPrefix: "jobs.query", + wantHostname: "group:web", + wantErr: false, + }, + { + name: "when parsing hierarchical label subject", + subject: "jobs.query.label.group.web.dev.us-east", + wantPrefix: "jobs.query", + wantHostname: "group:web.dev.us-east", + wantErr: false, + }, + { + name: "when parsing invalid 4-part subject without host prefix", + subject: "jobs.query.invalid.server1", wantErr: true, }, { @@ -271,13 +292,14 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { tests := []struct { name string hostname string + labels map[string]string want []string }{ { name: "when building subscription pattern for specific hostname", hostname: "web-server-01", want: []string{ - "jobs.*.web-server-01", + "jobs.*.host.web-server-01", "jobs.*._any", "jobs.*._all", }, @@ -286,7 +308,7 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { name: "when building subscription pattern for localhost", hostname: "localhost", want: []string{ - "jobs.*.localhost", + "jobs.*.host.localhost", "jobs.*._any", "jobs.*._all", }, @@ -295,16 +317,40 @@ func (suite *SubjectsPublicTestSuite) TestBuildWorkerSubscriptionPattern() { name: "when building subscription pattern with complex hostname", hostname: "api.example.com", want: []string{ - "jobs.*.api.example.com", + "jobs.*.host.api.example.com", + "jobs.*._any", + "jobs.*._all", + }, + }, + { + name: "when building with hierarchical label", + hostname: "web-01", + labels: map[string]string{"group": "web.dev.us-east"}, + want: []string{ + "jobs.*.host.web-01", "jobs.*._any", "jobs.*._all", + "jobs.*.label.group.web", + "jobs.*.label.group.web.dev", + "jobs.*.label.group.web.dev.us-east", + }, + }, + { + name: "when building with flat label", + hostname: "web-01", + labels: map[string]string{"team": "platform"}, + want: []string{ + "jobs.*.host.web-01", + "jobs.*._any", + "jobs.*._all", + "jobs.*.label.team.platform", }, }, } for _, tt := range tests { suite.Run(tt.name, func() { - got := job.BuildWorkerSubscriptionPattern(tt.hostname) + got := job.BuildWorkerSubscriptionPattern(tt.hostname, tt.labels) suite.Equal(tt.want, got) }) } @@ -412,6 +458,254 @@ func (suite *SubjectsPublicTestSuite) TestIsSpecialHostname() { } } +func (suite *SubjectsPublicTestSuite) TestValidateLabel() { + tests := []struct { + name string + key string + value string + wantErr bool + }{ + { + name: "when key and value are simple alphanumeric", + key: "role", + value: "web", + }, + { + name: "when value has hyphens and underscores", + key: "env", + value: "us-east_1", + }, + { + name: "when value is hierarchical with dots", + key: "group", + value: "web.dev.us-east", + }, + { + name: "when key contains dots", + key: "my.key", + value: "web", + wantErr: true, + }, + { + name: "when key contains colon", + key: "my:key", + value: "web", + wantErr: true, + }, + { + name: "when value segment contains spaces", + key: "group", + value: "web.dev server", + wantErr: true, + }, + { + name: "when value has empty segment", + key: "group", + value: "web..dev", + wantErr: true, + }, + { + name: "when key is empty", + key: "", + value: "web", + wantErr: true, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + err := job.ValidateLabel(tt.key, tt.value) + if tt.wantErr { + suite.Error(err) + } else { + suite.NoError(err) + } + }) + } +} + +func (suite *SubjectsPublicTestSuite) TestParseTarget() { + tests := []struct { + name string + target string + wantRouting string + wantKey string + wantValue string + }{ + { + name: "when target is _any", + target: "_any", + wantRouting: "_any", + }, + { + name: "when target is _all", + target: "_all", + wantRouting: "_all", + }, + { + name: "when target is a hostname", + target: "server1", + wantRouting: "host", + wantKey: "server1", + }, + { + name: "when target is a flat label", + target: "role:web", + wantRouting: "label", + wantKey: "role", + wantValue: "web", + }, + { + name: "when target is a hierarchical label", + target: "group:web.dev.us-east", + wantRouting: "label", + wantKey: "group", + wantValue: "web.dev.us-east", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + rt, key, value := job.ParseTarget(tt.target) + suite.Equal(tt.wantRouting, rt) + suite.Equal(tt.wantKey, key) + suite.Equal(tt.wantValue, value) + }) + } +} + +func (suite *SubjectsPublicTestSuite) TestBuildSubjectFromTarget() { + tests := []struct { + name string + prefix string + target string + want string + }{ + { + name: "when target is _any", + prefix: "jobs.query", + target: "_any", + want: "jobs.query._any", + }, + { + name: "when target is _all", + prefix: "jobs.modify", + target: "_all", + want: "jobs.modify._all", + }, + { + name: "when target is a hostname", + prefix: "jobs.query", + target: "server1", + want: "jobs.query.host.server1", + }, + { + name: "when target is a flat label", + prefix: "jobs.query", + target: "role:web", + want: "jobs.query.label.role.web", + }, + { + name: "when target is a hierarchical label", + prefix: "jobs.query", + target: "group:web.dev.us-east", + want: "jobs.query.label.group.web.dev.us-east", + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + got := job.BuildSubjectFromTarget(tt.prefix, tt.target) + suite.Equal(tt.want, got) + }) + } +} + +func (suite *SubjectsPublicTestSuite) TestIsBroadcastTarget() { + tests := []struct { + name string + target string + want bool + }{ + { + name: "when target is _all", + target: "_all", + want: true, + }, + { + name: "when target is a label", + target: "role:web", + want: true, + }, + { + name: "when target is a hierarchical label", + target: "group:web.dev", + want: true, + }, + { + name: "when target is _any", + target: "_any", + want: false, + }, + { + name: "when target is a hostname", + target: "server1", + want: false, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + got := job.IsBroadcastTarget(tt.target) + suite.Equal(tt.want, got) + }) + } +} + +func (suite *SubjectsPublicTestSuite) TestBuildLabelSubjects() { + tests := []struct { + name string + key string + value string + want []string + }{ + { + name: "when value is flat", + key: "role", + value: "web", + want: []string{ + "jobs.*.label.role.web", + }, + }, + { + name: "when value is hierarchical with two levels", + key: "group", + value: "web.dev", + want: []string{ + "jobs.*.label.group.web", + "jobs.*.label.group.web.dev", + }, + }, + { + name: "when value is hierarchical with three levels", + key: "group", + value: "web.dev.us-east", + want: []string{ + "jobs.*.label.group.web", + "jobs.*.label.group.web.dev", + "jobs.*.label.group.web.dev.us-east", + }, + }, + } + + for _, tt := range tests { + suite.Run(tt.name, func() { + got := job.BuildLabelSubjects(tt.key, tt.value) + suite.Equal(tt.want, got) + }) + } +} + func TestSubjectsPublicTestSuite(t *testing.T) { suite.Run(t, new(SubjectsPublicTestSuite)) } diff --git a/internal/job/types.go b/internal/job/types.go index f910628c..d85b0792 100644 --- a/internal/job/types.go +++ b/internal/job/types.go @@ -222,6 +222,8 @@ type SystemShutdownData struct { type WorkerInfo struct { // Hostname is the hostname of the worker. Hostname string `json:"hostname"` + // Labels are the key-value labels configured on the worker. + Labels map[string]string `json:"labels,omitempty"` } // SystemStatusResponse aggregates system status information from multiple providers. diff --git a/internal/job/worker/consumer.go b/internal/job/worker/consumer.go index 78150d06..c0e0c382 100644 --- a/internal/job/worker/consumer.go +++ b/internal/job/worker/consumer.go @@ -23,7 +23,9 @@ package worker import ( "context" + "fmt" "log/slog" + "strings" "time" "github.com/nats-io/nats.go" @@ -60,10 +62,36 @@ func (w *Worker) consumeQueryJobs( }, { name: "query_direct_" + sanitizedHostname, - filter: "jobs.query." + hostname, + filter: "jobs.query.host." + hostname, }, } + // Add label-based consumers with hierarchical prefix subscriptions. + // For "group: web.dev.us-east", creates consumers for each prefix level: + // jobs.query.label.group.web + // jobs.query.label.group.web.dev + // jobs.query.label.group.web.dev.us-east + for key, value := range w.appConfig.Job.Worker.Labels { + segments := strings.Split(value, ".") + for i := range segments { + prefix := strings.Join(segments[:i+1], ".") + sanitizedPrefix := job.SanitizeHostname(prefix) + consumers = append(consumers, struct { + name string + filter string + queueGroup string + }{ + name: fmt.Sprintf( + "query_label_%s_%s_%s", + key, + sanitizedPrefix, + sanitizedHostname, + ), + filter: fmt.Sprintf("jobs.query.label.%s.%s", key, prefix), + }) + } + } + for _, consumer := range consumers { // Create the consumer first if err := w.createConsumer(ctx, streamName, consumer.name, consumer.filter); err != nil { @@ -130,10 +158,32 @@ func (w *Worker) consumeModifyJobs( }, { name: "modify_direct_" + sanitizedHostname, - filter: "jobs.modify." + hostname, + filter: "jobs.modify.host." + hostname, }, } + // Add label-based consumers with hierarchical prefix subscriptions. + for key, value := range w.appConfig.Job.Worker.Labels { + segments := strings.Split(value, ".") + for i := range segments { + prefix := strings.Join(segments[:i+1], ".") + sanitizedPrefix := job.SanitizeHostname(prefix) + consumers = append(consumers, struct { + name string + filter string + queueGroup string + }{ + name: fmt.Sprintf( + "modify_label_%s_%s_%s", + key, + sanitizedPrefix, + sanitizedHostname, + ), + filter: fmt.Sprintf("jobs.modify.label.%s.%s", key, prefix), + }) + } + } + for _, consumer := range consumers { // Create the consumer first if err := w.createConsumer(ctx, streamName, consumer.name, consumer.filter); err != nil { diff --git a/internal/job/worker/consumer_test.go b/internal/job/worker/consumer_test.go index ff35a410..b1aeef4a 100644 --- a/internal/job/worker/consumer_test.go +++ b/internal/job/worker/consumer_test.go @@ -195,6 +195,23 @@ func (s *ConsumerTestSuite) TestConsumeQueryJobs() { }, expectErr: false, }, + { + name: "with labels creates extra consumers", + hostname: "test-worker", + setupMocks: func() { + // 3 base + 3 prefix levels for group:web.dev.us-east = 6 total + s.mockJobClient.EXPECT(). + CreateOrUpdateConsumer(gomock.Any(), "test-stream", gomock.Any()). + Return(nil). + Times(6) + + s.mockJobClient.EXPECT(). + ConsumeJobs(gomock.Any(), "test-stream", gomock.Any(), gomock.Any(), gomock.Any()). + Return(context.Canceled). + Times(6) + }, + expectErr: false, + }, } for _, tt := range tests { @@ -202,6 +219,15 @@ func (s *ConsumerTestSuite) TestConsumeQueryJobs() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Set labels for the label-specific test case + if tt.name == "with labels creates extra consumers" { + s.worker.appConfig.Job.Worker.Labels = map[string]string{ + "group": "web.dev.us-east", + } + } else { + s.worker.appConfig.Job.Worker.Labels = nil + } + tt.setupMocks() err := s.worker.consumeQueryJobs(ctx, tt.hostname) @@ -287,6 +313,23 @@ func (s *ConsumerTestSuite) TestConsumeModifyJobs() { }, expectErr: false, }, + { + name: "with labels creates extra consumers", + hostname: "test-worker", + setupMocks: func() { + // 3 base + 3 prefix levels for group:web.dev.us-east = 6 total + s.mockJobClient.EXPECT(). + CreateOrUpdateConsumer(gomock.Any(), "test-stream", gomock.Any()). + Return(nil). + Times(6) + + s.mockJobClient.EXPECT(). + ConsumeJobs(gomock.Any(), "test-stream", gomock.Any(), gomock.Any(), gomock.Any()). + Return(context.Canceled). + Times(6) + }, + expectErr: false, + }, } for _, tt := range tests { @@ -294,6 +337,15 @@ func (s *ConsumerTestSuite) TestConsumeModifyJobs() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Set labels for the label-specific test case + if tt.name == "with labels creates extra consumers" { + s.worker.appConfig.Job.Worker.Labels = map[string]string{ + "group": "web.dev.us-east", + } + } else { + s.worker.appConfig.Job.Worker.Labels = nil + } + tt.setupMocks() err := s.worker.consumeModifyJobs(ctx, tt.hostname) diff --git a/internal/job/worker/processor.go b/internal/job/worker/processor.go index a12e0e62..8dbc7f71 100644 --- a/internal/job/worker/processor.go +++ b/internal/job/worker/processor.go @@ -92,7 +92,7 @@ func (w *Worker) processNetworkOperation( } } -// getSystemHostname retrieves the system hostname. +// getSystemHostname retrieves the system hostname and worker labels. func (w *Worker) getSystemHostname() (json.RawMessage, error) { hostProvider := w.getHostProvider() hostname, err := hostProvider.GetHostname() @@ -104,6 +104,10 @@ func (w *Worker) getSystemHostname() (json.RawMessage, error) { "hostname": hostname, } + if len(w.appConfig.Job.Worker.Labels) > 0 { + result["labels"] = w.appConfig.Job.Worker.Labels + } + return json.Marshal(result) } diff --git a/internal/job/worker/processor_test.go b/internal/job/worker/processor_test.go index 6d43a989..4198e490 100644 --- a/internal/job/worker/processor_test.go +++ b/internal/job/worker/processor_test.go @@ -380,6 +380,7 @@ func (s *ProcessorTestSuite) TestSystemOperations() { tests := []struct { name string operation string + labels map[string]string expectError bool validate func(json.RawMessage) }{ @@ -393,6 +394,21 @@ func (s *ProcessorTestSuite) TestSystemOperations() { s.Contains(response, "hostname") }, }, + { + name: "get hostname with labels", + operation: "hostname.get", + labels: map[string]string{"group": "web.dev.us-east"}, + validate: func(result json.RawMessage) { + var response map[string]interface{} + err := json.Unmarshal(result, &response) + s.NoError(err) + s.Contains(response, "hostname") + s.Contains(response, "labels") + labels, ok := response["labels"].(map[string]interface{}) + s.True(ok) + s.Equal("web.dev.us-east", labels["group"]) + }, + }, { name: "get system status", operation: "status.get", @@ -417,6 +433,8 @@ func (s *ProcessorTestSuite) TestSystemOperations() { for _, tt := range tests { s.Run(tt.name, func() { + s.worker.appConfig.Job.Worker.Labels = tt.labels + request := job.Request{ Type: job.TypeQuery, Category: "system", diff --git a/internal/job/worker/server.go b/internal/job/worker/server.go index e545e4ca..af0eb9a1 100644 --- a/internal/job/worker/server.go +++ b/internal/job/worker/server.go @@ -50,6 +50,7 @@ func (w *Worker) run( slog.String("hostname", hostname), slog.String("queue_group", w.appConfig.Job.Worker.QueueGroup), slog.Int("max_jobs", w.appConfig.Job.Worker.MaxJobs), + slog.Any("labels", w.appConfig.Job.Worker.Labels), ) // Start consuming messages for different job types diff --git a/osapi.yaml b/osapi.yaml index fb3eb522..80f01d82 100644 --- a/osapi.yaml +++ b/osapi.yaml @@ -75,3 +75,5 @@ job: queue_group: job-workers hostname: "" # defaults to system hostname max_jobs: 10 + labels: + group: web.dev.us-east # hierarchical: --target group:web, group:web.dev, etc.