diff --git a/CLAUDE.md b/CLAUDE.md
index 84d9c6cc..97df6787 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -38,7 +38,7 @@ go test -run TestName -v ./internal/job/... # Run a single test
- **`internal/agent/`** - Node agent: consumer/handler/processor pipeline for job execution
- **`internal/provider/`** - Operation implementations: `node/{host,disk,mem,load}`, `network/{dns,ping}`
- **`internal/config/`** - Viper-based config from `osapi.yaml`
-- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`osapi/` client library, `orchestrator/` DAG runner)
+- **`pkg/sdk/`** - Go SDK for programmatic REST API access (`client/` client library, `orchestrator/` DAG runner)
- Shared `nats-client` and `nats-server` are sibling repos linked via `replace` in `go.mod`
- **`github/`** - Temporary GitHub org config tooling (`repos.json` for declarative repo settings, `sync.sh` for drift detection via `gh` CLI). Untracked and intended to move to its own repo.
@@ -171,7 +171,7 @@ Create `internal/api/{domain}/`:
### Step 5: Update SDK
-The SDK client library lives in `pkg/sdk/osapi/`. Its generated HTTP client
+The SDK client library lives in `pkg/sdk/client/`. Its generated HTTP client
uses the same combined OpenAPI spec as the server
(`internal/api/gen/api.yaml`).
@@ -180,15 +180,15 @@ uses the same combined OpenAPI spec as the server
1. Make changes to `internal/api/{domain}/gen/api.yaml` in this repo
2. Run `just generate` to regenerate server code (this also regenerates the
combined spec via `redocly join`)
-3. Run `go generate ./pkg/sdk/osapi/gen/...` to regenerate the SDK client
-4. Update the SDK service wrappers in `pkg/sdk/osapi/{domain}.go` if new
+3. Run `go generate ./pkg/sdk/client/gen/...` to regenerate the SDK client
+4. Update the SDK service wrappers in `pkg/sdk/client/{domain}.go` if new
response codes were added
5. Update CLI switch blocks in `cmd/` if new response codes were added
**When adding a new API domain:**
-1. Add a service wrapper in `pkg/sdk/osapi/{domain}.go`
-2. Run `go generate ./pkg/sdk/osapi/gen/...` to pick up the new domain's
+1. Add a service wrapper in `pkg/sdk/client/{domain}.go`
+2. Run `go generate ./pkg/sdk/client/gen/...` to pick up the new domain's
spec from the combined `api.yaml`
### Step 6: CLI Commands
@@ -253,7 +253,11 @@ Three test layers:
middleware with mocked backends.
- **Integration tests** (`test/integration/`) — build and start a real
`osapi` binary, exercise CLI commands end-to-end. Guarded by
- `//go:build integration` tag, run with `just go::unit-int`.
+ `//go:build integration` tag, run with `just go::unit-int`. New API
+ domains should include a `{domain}_test.go` smoke suite. Write tests
+ (mutations) must be guarded by `skipWrite(s.T())` so CI can run
+ read-only tests by default (`OSAPI_INTEGRATION_WRITES=1` enables
+ writes).
Conventions:
- ALL tests in `internal/job/` MUST use `testify/suite` with table-driven patterns
diff --git a/README.md b/README.md
index bf3c77a1..2f2c55a6 100644
--- a/README.md
+++ b/README.md
@@ -29,17 +29,15 @@ them to be used as appliances.
[Getting Started]: https://osapi-io.github.io/osapi/
[API]: https://osapi-io.github.io/osapi/category/api
[Usage]: https://osapi-io.github.io/osapi/sidebar/usage
-[SDK]: https://osapi-io.github.io/osapi/sidebar/sdk/sdk
+[SDK]: https://osapi-io.github.io/osapi/sidebar/sdk
## 🔗 Sister Projects
| Project | Description |
| --- | --- |
-| [osapi-orchestrator][] | A Go package for orchestrating operations across OSAPI-managed hosts — typed operations, chaining, conditions, and result decoding built on top of the osapi-sdk engine |
| [nats-client][] | A Go package for connecting to and interacting with a NATS server |
| [nats-server][] | A Go package for running an embedded NATS server |
-[osapi-orchestrator]: https://github.com/osapi-io/osapi-orchestrator
[nats-client]: https://github.com/osapi-io/nats-client
[nats-server]: https://github.com/osapi-io/nats-server
diff --git a/cmd/client.go b/cmd/client.go
index 4c606e6b..c8c7f733 100644
--- a/cmd/client.go
+++ b/cmd/client.go
@@ -24,7 +24,7 @@ import (
"context"
"log/slog"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/spf13/viper"
@@ -33,7 +33,7 @@ import (
)
var (
- sdkClient *osapi.Client
+ sdkClient *client.Client
tracerShutdown func(context.Context) error
)
@@ -61,10 +61,10 @@ var clientCmd = &cobra.Command{
slog.String("api.client.url", appConfig.API.URL),
)
- sdkClient = osapi.New(
+ sdkClient = client.New(
appConfig.API.URL,
appConfig.API.Client.Security.BearerToken,
- osapi.WithLogger(logger),
+ client.WithLogger(logger),
)
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
diff --git a/cmd/client_agent_get.go b/cmd/client_agent_get.go
index b0d3b216..7eabf44d 100644
--- a/cmd/client_agent_get.go
+++ b/cmd/client_agent_get.go
@@ -25,7 +25,7 @@ import (
"strings"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -57,7 +57,7 @@ var clientAgentGetCmd = &cobra.Command{
// displayAgentGetDetail renders detailed agent information in PrintKV style.
func displayAgentGetDetail(
- data *osapi.Agent,
+ data *client.Agent,
) {
fmt.Println()
diff --git a/cmd/client_audit_export.go b/cmd/client_audit_export.go
index 16b9efbf..d57159a7 100644
--- a/cmd/client_audit_export.go
+++ b/cmd/client_audit_export.go
@@ -25,7 +25,7 @@ import (
"fmt"
"strconv"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/audit/export"
@@ -60,7 +60,7 @@ entry as a JSON line (JSONL format). Requires audit:read permission.
func writeExport(
ctx context.Context,
- items []osapi.AuditEntry,
+ items []client.AuditEntry,
totalItems int,
) {
var exporter export.Exporter
diff --git a/cmd/client_file_upload.go b/cmd/client_file_upload.go
index 16393ab9..f8fa28e2 100644
--- a/cmd/client_file_upload.go
+++ b/cmd/client_file_upload.go
@@ -28,7 +28,7 @@ import (
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
- osapi "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
// clientFileUploadCmd represents the clientFileUpload command.
@@ -49,9 +49,9 @@ var clientFileUploadCmd = &cobra.Command{
}
defer func() { _ = f.Close() }()
- var opts []osapi.UploadOption
+ var opts []client.UploadOption
if force {
- opts = append(opts, osapi.WithForce())
+ opts = append(opts, client.WithForce())
}
resp, err := sdkClient.File.Upload(ctx, name, contentType, f, opts...)
diff --git a/cmd/client_health_status.go b/cmd/client_health_status.go
index 3d8ac317..8c3135c8 100644
--- a/cmd/client_health_status.go
+++ b/cmd/client_health_status.go
@@ -23,7 +23,7 @@ package cmd
import (
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -55,7 +55,7 @@ Requires authentication.
// displayStatusHealth renders health status output with system metrics.
func displayStatusHealth(
- data *osapi.SystemStatus,
+ data *client.SystemStatus,
) {
fmt.Println()
cli.PrintKV("Status", data.Status, "Version", data.Version, "Uptime", data.Uptime)
diff --git a/cmd/client_job_list.go b/cmd/client_job_list.go
index e38d508f..a33afbc4 100644
--- a/cmd/client_job_list.go
+++ b/cmd/client_job_list.go
@@ -26,7 +26,7 @@ import (
"strings"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -44,7 +44,7 @@ var clientJobListCmd = &cobra.Command{
offsetFlag, _ := cmd.Flags().GetInt("offset")
// Get jobs list (server-side pagination)
- jobsResp, err := sdkClient.Job.List(ctx, osapi.ListParams{
+ jobsResp, err := sdkClient.Job.List(ctx, client.ListParams{
Status: statusFilter,
Limit: limitFlag,
Offset: offsetFlag,
@@ -76,7 +76,7 @@ var clientJobListCmd = &cobra.Command{
}
func displayJobListJSON(
- jobs []osapi.JobDetail,
+ jobs []client.JobDetail,
totalItems int,
statusCounts map[string]int,
statusFilter string,
@@ -129,7 +129,7 @@ func displayJobListSummary(
}
func displayJobListTable(
- jobs []osapi.JobDetail,
+ jobs []client.JobDetail,
) {
if len(jobs) == 0 {
return
diff --git a/cmd/client_job_run.go b/cmd/client_job_run.go
index 85279441..29b48847 100644
--- a/cmd/client_job_run.go
+++ b/cmd/client_job_run.go
@@ -29,7 +29,7 @@ import (
"os"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -108,7 +108,7 @@ This combines job submission and retrieval into a single command for convenience
func checkJobComplete(
ctx context.Context,
- jobService *osapi.JobService,
+ jobService *client.JobService,
jobID string,
) bool {
resp, err := jobService.Get(ctx, jobID)
diff --git a/cmd/client_job_status.go b/cmd/client_job_status.go
deleted file mode 100644
index a3729d38..00000000
--- a/cmd/client_job_status.go
+++ /dev/null
@@ -1,225 +0,0 @@
-// Copyright (c) 2025 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package cmd
-
-import (
- "context"
- "encoding/json"
- "fmt"
- "strings"
- "time"
-
- tea "github.com/charmbracelet/bubbletea"
- "github.com/charmbracelet/lipgloss"
- "github.com/spf13/cobra"
-
- "github.com/retr0h/osapi/internal/cli"
-)
-
-type jobsModel struct {
- jobsStatus string
- lastUpdate time.Time
- isLoading bool
- pollIntervalSecs int
-}
-
-func initialJobsModel(
- pollIntervalSecs int,
-) jobsModel {
- return jobsModel{
- jobsStatus: "Fetching jobs status...",
- lastUpdate: time.Now(),
- isLoading: true,
- pollIntervalSecs: pollIntervalSecs,
- }
-}
-
-func (m jobsModel) tickCmd() tea.Cmd {
- pollInterval := time.Duration(m.pollIntervalSecs) * time.Second
-
- return tea.Tick(pollInterval, func(t time.Time) tea.Msg {
- return t
- })
-}
-
-func fetchJobsCmd() tea.Cmd {
- return func() tea.Msg {
- return fetchJobsStatus()
- }
-}
-
-func (m jobsModel) Init() tea.Cmd {
- return tea.Batch(fetchJobsCmd(), m.tickCmd())
-}
-
-func (m jobsModel) Update(
- msg tea.Msg,
-) (tea.Model, tea.Cmd) {
- switch msg := msg.(type) {
- case tea.KeyMsg:
- if msg.String() == "q" {
- return m, tea.Quit
- }
- case string:
- m.jobsStatus = msg
- m.lastUpdate = time.Now()
- m.isLoading = false
- return m, m.tickCmd()
- case time.Time:
- return m, fetchJobsCmd()
- }
- return m, nil
-}
-
-func (m jobsModel) View() string {
- var (
- titleStyle = lipgloss.NewStyle().Bold(true).Foreground(cli.Purple)
- timeStyle = lipgloss.NewStyle().Foreground(cli.LightGray).Italic(true)
- borderStyle = lipgloss.NewStyle().
- Border(lipgloss.RoundedBorder()).
- Padding(1).
- Margin(2).
- BorderForeground(cli.Purple)
- )
-
- title := titleStyle.Render("Jobs Queue Status")
-
- styledStatus := styleStatusText(m.jobsStatus)
-
- lastUpdated := timeStyle.Render(
- fmt.Sprintf("Last Updated: %v", m.lastUpdate.Format(time.RFC1123)),
- )
- quitInstruction := timeStyle.Render("Press 'q' to quit")
-
- return borderStyle.Render(
- fmt.Sprintf("%s\n\n%s\n\n%s\n\n%s", title, styledStatus, lastUpdated, quitInstruction),
- )
-}
-
-func styleStatusText(
- statusText string,
-) string {
- var (
- keyStyle = lipgloss.NewStyle().Foreground(cli.Gray)
- valueStyle = lipgloss.NewStyle().Foreground(cli.Teal)
- sectionStyle = lipgloss.NewStyle().Foreground(cli.Gray)
- )
-
- lines := strings.Split(statusText, "\n")
- var styledLines []string
-
- for _, line := range lines {
- if strings.HasSuffix(strings.TrimSpace(line), ":") && !strings.HasPrefix(line, " ") {
- styledLines = append(styledLines, sectionStyle.Render(line))
- } else if strings.Contains(line, ":") {
- parts := strings.SplitN(line, ":", 2)
- if len(parts) == 2 {
- key := strings.TrimSpace(parts[0])
- value := strings.TrimSpace(parts[1])
-
- indent := ""
- if strings.HasPrefix(line, " ") {
- indent = " "
- }
-
- styledLine := indent + keyStyle.Render(key+":") + " " + valueStyle.Render(value)
- styledLines = append(styledLines, styledLine)
- } else {
- styledLines = append(styledLines, line)
- }
- } else {
- styledLines = append(styledLines, line)
- }
- }
-
- return strings.Join(styledLines, "\n")
-}
-
-func fetchJobsStatus() string {
- resp, err := sdkClient.Job.QueueStats(context.Background())
- if err != nil {
- return fmt.Sprintf("Error fetching jobs: %v", err)
- }
-
- stats := &resp.Data
-
- if stats.TotalJobs == 0 {
- return "Job queue is empty (0 jobs total)"
- }
-
- statusDisplay := "Jobs Queue Status:\n"
- statusDisplay += fmt.Sprintf(" Total Jobs: %d\n", stats.TotalJobs)
- statusDisplay += fmt.Sprintf(" Unprocessed: %d\n", stats.StatusCounts["unprocessed"])
- statusDisplay += fmt.Sprintf(" Processing: %d\n", stats.StatusCounts["processing"])
- statusDisplay += fmt.Sprintf(" Completed: %d\n", stats.StatusCounts["completed"])
- statusDisplay += fmt.Sprintf(" Failed: %d\n", stats.StatusCounts["failed"])
-
- if stats.DlqCount > 0 {
- statusDisplay += fmt.Sprintf(" Dead Letter Queue: %d\n", stats.DlqCount)
- }
-
- return statusDisplay
-}
-
-func fetchJobsStatusJSON() string {
- resp, err := sdkClient.Job.QueueStats(context.Background())
- if err != nil {
- errorResult := map[string]interface{}{
- "error": fmt.Sprintf("Error fetching jobs: %v", err),
- }
- resultJSON, _ := json.Marshal(errorResult)
- return string(resultJSON)
- }
-
- return string(resp.RawJSON())
-}
-
-// clientJobStatusCmd represents the clientJobsStatus command.
-var clientJobStatusCmd = &cobra.Command{
- Use: "status",
- Short: "Display the jobs queue status",
- Long: `Displays the jobs queue status with automatic updates.
-Shows job counts by status (unprocessed/processing/completed/failed)
-and operation types with live refresh.`,
- Run: func(cmd *cobra.Command, _ []string) {
- pollIntervalSeconds, _ := cmd.Flags().GetInt("poll-interval-seconds")
-
- if jsonOutput {
- status := fetchJobsStatusJSON()
- fmt.Println(status)
- return
- }
-
- p := tea.NewProgram(initialJobsModel(pollIntervalSeconds), tea.WithAltScreen())
- _, err := p.Run()
- if err != nil {
- status := fetchJobsStatus()
- fmt.Println(status)
- }
- },
-}
-
-func init() {
- clientJobCmd.AddCommand(clientJobStatusCmd)
-
- clientJobStatusCmd.PersistentFlags().
- Int("poll-interval-seconds", 30, "The interval (in seconds) between polling operations")
-}
diff --git a/cmd/client_node_command_exec.go b/cmd/client_node_command_exec.go
index cf455c32..c53f811e 100644
--- a/cmd/client_node_command_exec.go
+++ b/cmd/client_node_command_exec.go
@@ -25,7 +25,7 @@ import (
"os"
"strconv"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -53,7 +53,7 @@ var clientNodeCommandExecCmd = &cobra.Command{
}
}
- resp, err := sdkClient.Node.Exec(ctx, osapi.ExecRequest{
+ resp, err := sdkClient.Node.Exec(ctx, client.ExecRequest{
Command: command,
Args: args,
Cwd: cwd,
@@ -124,7 +124,7 @@ var clientNodeCommandExecCmd = &cobra.Command{
}
func buildRawResults(
- items []osapi.CommandResult,
+ items []client.CommandResult,
) []cli.RawResult {
results := make([]cli.RawResult, 0, len(items))
for _, r := range items {
diff --git a/cmd/client_node_command_shell.go b/cmd/client_node_command_shell.go
index 1356edb9..b5c2de44 100644
--- a/cmd/client_node_command_shell.go
+++ b/cmd/client_node_command_shell.go
@@ -25,7 +25,7 @@ import (
"os"
"strconv"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -52,7 +52,7 @@ var clientNodeCommandShellCmd = &cobra.Command{
}
}
- resp, err := sdkClient.Node.Shell(ctx, osapi.ShellRequest{
+ resp, err := sdkClient.Node.Shell(ctx, client.ShellRequest{
Command: command,
Cwd: cwd,
Timeout: timeout,
diff --git a/cmd/client_node_file_deploy.go b/cmd/client_node_file_deploy.go
index c825befc..7db9c232 100644
--- a/cmd/client_node_file_deploy.go
+++ b/cmd/client_node_file_deploy.go
@@ -24,7 +24,7 @@ import (
"fmt"
"strings"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -59,7 +59,7 @@ SHA-256 idempotency ensures unchanged files are not rewritten.`,
vars := parseVarFlags(varFlags)
- resp, err := sdkClient.Node.FileDeploy(ctx, osapi.FileDeployOpts{
+ resp, err := sdkClient.Node.FileDeploy(ctx, client.FileDeployOpts{
Target: host,
ObjectName: objectName,
Path: path,
diff --git a/cmd/client_node_status_get.go b/cmd/client_node_status_get.go
index 428c08fa..6962dd0e 100644
--- a/cmd/client_node_status_get.go
+++ b/cmd/client_node_status_get.go
@@ -23,7 +23,7 @@ package cmd
import (
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/spf13/cobra"
"github.com/retr0h/osapi/internal/cli"
@@ -62,7 +62,7 @@ var clientNodeStatusGetCmd = &cobra.Command{
// For a single non-broadcast result, shows detailed output; otherwise shows a summary table.
func displayNodeStatusCollection(
target string,
- data *osapi.Collection[osapi.NodeStatus],
+ data *client.Collection[client.NodeStatus],
) {
if len(data.Results) == 1 && target != "_all" {
displayNodeStatusDetail(&data.Results[0])
@@ -105,7 +105,7 @@ func displayNodeStatusCollection(
// displayNodeStatusDetail renders a single node status response with full details.
func displayNodeStatusDetail(
- data *osapi.NodeStatus,
+ data *client.NodeStatus,
) {
fmt.Println()
diff --git a/docs/docs/gen/api/get-queue-statistics.api.mdx b/docs/docs/gen/api/get-queue-statistics.api.mdx
deleted file mode 100644
index 87b73628..00000000
--- a/docs/docs/gen/api/get-queue-statistics.api.mdx
+++ /dev/null
@@ -1,458 +0,0 @@
----
-id: get-queue-statistics
-title: "Get queue statistics"
-description: "Retrieve statistics about the job queue."
-sidebar_label: "Get queue statistics"
-hide_title: true
-hide_table_of_contents: true
-api: eJztVk1v00AQ/SurOZs2LXDxLSBaFQlU2iIOVRSt7Um8qb3rzs4WgrX/Hc06TZ0mhyKBxIFTbGc+3ry3OzM9VOhLMh0bZyGHK2Qy+IDKs2bj2ZRe6cIFVlyjWrlC3QcMeAQZsF56yG/hoyvmn7TVS2zR8nx6eTFfuWLuOiQtUT3MMvBYBjK8hvy2h3eoCWkauBb/lStyQl3BLM4yIPSdsx495D2cTibyswvxiwAY4RMspbOMlsVYd11jypT5eOXFowdf1thqeeJ1h5CDK1ZYMmTQkeBkM+Rjx7oR9H5kayzjEgmyZzhuxFjZ0BZIyi2EHK+MTURtScIfuu0ahPzNacxAQAc/L12w7A/B0VVlJLxuLneB7UKJz7G8l4hbEMVaDZmOkmVzP2R8QU2fD1ZToa5Ug8xIj5XFmAEblsoGQa5Zs7/aiAcxisGbycm+fF+tDlw7Mj+xUq/U9PJC3eFaEd4HQ1j9OTGRyNHIzDMZu9wreapG71K5VJx8FdealSvLQITVjppwpk2DlWKnaHxjHilH1qbxL0i+1VttfEa3LYE4mLYKKKkt8ndHd4pNiy5wSl26Cl9yeLdFisNOkreTyVjdD2K1J+zrfWHPHBWmqtCqV+rC+rBYmNKgZdUhtcb71Ar+q/vvq/v2UNdNho90GLscGsHfacP/tf1L2sYMWuTaVZDDEhPxWoYwHK9ccTzQADKs6QFJhvtocl+LeIM+4/m9xVszd+KbzCCHIhlBtnk4c9Rqhhw+frtJ08PYhUvuG7TTpfSKp0VCJgNkIECGwk+OJkcTIapznludTpTVKdc58t55fE5c/3Q6f2vLGapj/MHHXaONFQSBGgk4kJc2GHgc7rLs1M6zfO/7Qnv8Sk2M8vk+IK0HUh80GV1I3bezmEGNukJKu9EdroWMssRO9HnQTZD8e9dJdqWtlucfbmR52BXkmQAp+uYvbdej2H0/WNy4O7QxQrYBwfIOcRZj/AXUzojN
-sidebar_class_name: "get api-method"
-info_path: gen/api/agent-management-api
-custom_edit_url: null
----
-
-import ApiTabs from "@theme/ApiTabs";
-import DiscriminatorTabs from "@theme/DiscriminatorTabs";
-import MethodEndpoint from "@theme/ApiExplorer/MethodEndpoint";
-import SecuritySchemes from "@theme/ApiExplorer/SecuritySchemes";
-import MimeTabs from "@theme/MimeTabs";
-import ParamsItem from "@theme/ParamsItem";
-import ResponseSamples from "@theme/ResponseSamples";
-import SchemaItem from "@theme/SchemaItem";
-import SchemaTabs from "@theme/SchemaTabs";
-import Heading from "@theme/Heading";
-import OperationTabs from "@theme/OperationTabs";
-import TabItem from "@theme/TabItem";
-
-
-
-
-
-
-
-
-
-
-Retrieve statistics about the job queue.
-
-
-
-
-
-
-
-
- Queue statistics.
-
-
-
-
-
-
-
-
-
-
- Schema
-
-
-
-
-
-
-
-
-
-
-
- status_counts
-
- object
-
-
-
-
-
-
- Count of jobs by status.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Unauthorized - API key required
-
-
-
-
-
-
-
-
-
-
- Schema
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Forbidden - Insufficient permissions
-
-
-
-
-
-
-
-
-
-
- Schema
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Error retrieving queue statistics.
-
-
-
-
-
-
-
-
-
-
- Schema
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/docs/docs/gen/api/sidebar.ts b/docs/docs/gen/api/sidebar.ts
index aa4cb467..b7107556 100644
--- a/docs/docs/gen/api/sidebar.ts
+++ b/docs/docs/gen/api/sidebar.ts
@@ -166,12 +166,6 @@ const sidebar: SidebarsConfig = {
label: "List jobs",
className: "api-method get",
},
- {
- type: "doc",
- id: "gen/api/get-queue-statistics",
- label: "Get queue statistics",
- className: "api-method get",
- },
{
type: "doc",
id: "gen/api/get-job-by-id",
diff --git a/docs/docs/sidebar/architecture/job-architecture.md b/docs/docs/sidebar/architecture/job-architecture.md
index a338404d..7ba98299 100644
--- a/docs/docs/sidebar/architecture/job-architecture.md
+++ b/docs/docs/sidebar/architecture/job-architecture.md
@@ -37,8 +37,8 @@ into NATS JetStream:
- **Agents** — Processes jobs, updates status, stores results
All three use the **Job Client Layer** (`internal/job/client/`), which provides
-type-safe business logic operations (`CreateJob`, `GetQueueSummary`,
-`GetJobStatus`, `ListJobs`) on top of NATS JetStream.
+type-safe business logic operations (`CreateJob`, `GetJobStatus`, `ListJobs`) on
+top of NATS JetStream.
**NATS JetStream** provides three storage backends:
@@ -340,21 +340,6 @@ GET /api/v1/jobs/{job-id}
- Status events (`status.{id}.*`)
- Agent responses (`responses.{id}.*`)
-### Queue Statistics
-
-```json
-{
- "total_jobs": 42,
- "status_counts": {
- "submitted": 5,
- "processing": 2,
- "completed": 30,
- "failed": 5
- },
- "dlq_count": 0
-}
-```
-
## Agent Implementation
### Processing Flow
@@ -628,9 +613,6 @@ osapi client job get --job-id 550e8400-e29b-41d4-a716-446655440000
# Run job and wait for completion
osapi client job run --json-file operation.json --timeout 60
-# Monitor queue status
-osapi client job status --poll-interval-seconds 5
-
# Delete a job
osapi client job delete --job-id uuid-12345
diff --git a/docs/docs/sidebar/sdk/client/agent.md b/docs/docs/sidebar/sdk/client/agent.md
index b4e6581a..c518b2fe 100644
--- a/docs/docs/sidebar/sdk/client/agent.md
+++ b/docs/docs/sidebar/sdk/client/agent.md
@@ -26,7 +26,7 @@ resp, err := client.Agent.Get(ctx, "web-01")
## Example
See
-[`examples/sdk/osapi/agent.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/agent.go)
+[`examples/sdk/client/agent.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/agent.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/audit.md b/docs/docs/sidebar/sdk/client/audit.md
index 3fef0a70..dc0d0a74 100644
--- a/docs/docs/sidebar/sdk/client/audit.md
+++ b/docs/docs/sidebar/sdk/client/audit.md
@@ -30,7 +30,7 @@ resp, err := client.Audit.Export(ctx)
## Example
See
-[`examples/sdk/osapi/audit.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/audit.go)
+[`examples/sdk/client/audit.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/audit.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/client.md b/docs/docs/sidebar/sdk/client/client.md
index 4abd7157..db45cb9c 100644
--- a/docs/docs/sidebar/sdk/client/client.md
+++ b/docs/docs/sidebar/sdk/client/client.md
@@ -2,7 +2,7 @@
sidebar_position: 1
---
-# Client
+# Client Library
The `osapi` package provides a typed Go client for the OSAPI REST API. Create a
client with `New()` and use domain-specific services to interact with the API.
@@ -10,9 +10,9 @@ client with `New()` and use domain-specific services to interact with the API.
## Quick Start
```go
-import "github.com/retr0h/osapi/pkg/sdk/osapi"
+import "github.com/retr0h/osapi/pkg/sdk/client"
-client := osapi.New("http://localhost:8080", "your-jwt-token")
+client := client.New("http://localhost:8080", "your-jwt-token")
resp, err := client.Node.Hostname(ctx, "_any")
```
diff --git a/docs/docs/sidebar/sdk/client/file.md b/docs/docs/sidebar/sdk/client/file.md
index 825f3c0e..76f79d1d 100644
--- a/docs/docs/sidebar/sdk/client/file.md
+++ b/docs/docs/sidebar/sdk/client/file.md
@@ -59,7 +59,7 @@ resp, err := client.File.Upload(
// Force upload — skip SHA-256 check, always write.
resp, err := client.File.Upload(
ctx, "nginx.conf", "raw", bytes.NewReader(data),
- osapi.WithForce(),
+ client.WithForce(),
)
// Check if content differs without uploading.
@@ -78,7 +78,7 @@ resp, err := client.File.Get(ctx, "nginx.conf")
resp, err := client.File.Delete(ctx, "nginx.conf")
// Deploy a raw file to a specific host.
-resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{
+resp, err := client.Node.FileDeploy(ctx, client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
@@ -89,7 +89,7 @@ resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{
})
// Deploy a template file with variables.
-resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{
+resp, err := client.Node.FileDeploy(ctx, client.FileDeployOpts{
ObjectName: "app.conf.tmpl",
Path: "/etc/app/config.yaml",
ContentType: "template",
@@ -132,7 +132,7 @@ reports `Changed: false`.
## Example
See
-[`examples/sdk/osapi/file.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/file.go)
+[`examples/sdk/client/file.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/file.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/health.md b/docs/docs/sidebar/sdk/client/health.md
index bf0de274..53840fe6 100644
--- a/docs/docs/sidebar/sdk/client/health.md
+++ b/docs/docs/sidebar/sdk/client/health.md
@@ -30,7 +30,7 @@ resp, err := client.Health.Status(ctx)
## Example
See
-[`examples/sdk/osapi/health.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/health.go)
+[`examples/sdk/client/health.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/health.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/job.md b/docs/docs/sidebar/sdk/client/job.md
index 6301ffc0..2cea88fe 100644
--- a/docs/docs/sidebar/sdk/client/job.md
+++ b/docs/docs/sidebar/sdk/client/job.md
@@ -15,7 +15,6 @@ Async job queue operations.
| `List(ctx, params)` | List jobs with optional filters |
| `Delete(ctx, id)` | Delete a job by UUID |
| `Retry(ctx, id, target)` | Retry a failed job |
-| `QueueStats(ctx)` | Retrieve queue statistics |
## Usage
@@ -27,7 +26,7 @@ resp, err := client.Job.Create(ctx, map[string]any{
}, "_any")
// List completed jobs
-resp, err := client.Job.List(ctx, osapi.ListParams{
+resp, err := client.Job.List(ctx, client.ListParams{
Status: "completed",
Limit: 20,
})
@@ -39,7 +38,7 @@ resp, err := client.Job.Retry(ctx, "uuid-string", "_any")
## Example
See
-[`examples/sdk/osapi/job.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/job.go)
+[`examples/sdk/client/job.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/job.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/metrics.md b/docs/docs/sidebar/sdk/client/metrics.md
index 112d82c0..7291a4ec 100644
--- a/docs/docs/sidebar/sdk/client/metrics.md
+++ b/docs/docs/sidebar/sdk/client/metrics.md
@@ -22,7 +22,7 @@ fmt.Print(text)
## Example
See
-[`examples/sdk/osapi/metrics.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/metrics.go)
+[`examples/sdk/client/metrics.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/metrics.go)
for a complete working example.
## Permissions
diff --git a/docs/docs/sidebar/sdk/client/node.md b/docs/docs/sidebar/sdk/client/node.md
index ff9d03c1..802c9bdd 100644
--- a/docs/docs/sidebar/sdk/client/node.md
+++ b/docs/docs/sidebar/sdk/client/node.md
@@ -64,20 +64,20 @@ resp, err := client.Node.UpdateDNS(
)
// Execute a command
-resp, err := client.Node.Exec(ctx, osapi.ExecRequest{
+resp, err := client.Node.Exec(ctx, client.ExecRequest{
Command: "apt",
Args: []string{"install", "-y", "nginx"},
Target: "_all",
})
// Execute a shell command
-resp, err := client.Node.Shell(ctx, osapi.ShellRequest{
+resp, err := client.Node.Shell(ctx, client.ShellRequest{
Command: "ps aux | grep nginx",
Target: "_any",
})
// Deploy a file
-resp, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{
+resp, err := client.Node.FileDeploy(ctx, client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
@@ -94,11 +94,11 @@ resp, err := client.Node.FileStatus(
## Examples
See
-[`examples/sdk/osapi/node.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/node.go)
+[`examples/sdk/client/node.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/node.go)
for node info, and
-[`examples/sdk/osapi/network.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/network.go)
+[`examples/sdk/client/network.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/network.go)
and
-[`examples/sdk/osapi/command.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/osapi/command.go)
+[`examples/sdk/client/command.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/client/command.go)
for network and command examples.
## Permissions
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/_category_.json b/docs/docs/sidebar/sdk/orchestrator/features/_category_.json
new file mode 100644
index 00000000..4aaeb074
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Features",
+ "position": 3
+}
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/basic.md b/docs/docs/sidebar/sdk/orchestrator/features/basic.md
new file mode 100644
index 00000000..be94f3d8
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/basic.md
@@ -0,0 +1,38 @@
+---
+sidebar_position: 2
+---
+
+# Basic Plans
+
+Create a plan, add tasks with dependencies, and run them in order.
+
+## Usage
+
+```go
+client := client.New(url, token)
+plan := orchestrator.NewPlan(client)
+
+health := plan.TaskFunc("check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ return &orchestrator.Result{Changed: false}, err
+ },
+)
+
+hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+})
+hostname.DependsOn(health)
+
+report, err := plan.Run(context.Background())
+```
+
+`Task` creates an Op-based task (sends a job to an agent). `TaskFunc` embeds
+custom Go logic. `DependsOn` declares ordering.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/basic.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/basic.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md b/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md
new file mode 100644
index 00000000..4328e1e5
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md
@@ -0,0 +1,50 @@
+---
+sidebar_position: 8
+---
+
+# Broadcast Targeting
+
+Send operations to multiple agents and access per-host results.
+
+## Targets
+
+| Target | Behavior |
+| ----------- | -------------------------------- |
+| `_any` | Any single agent (load balanced) |
+| `_all` | Every registered agent |
+| `hostname` | Specific host |
+| `key:value` | Agents matching a label |
+
+`_all` and label selectors (`key:value`) are broadcast targets — the job runs on
+every matching agent and per-host results are available via `HostResults`.
+
+## Usage
+
+```go
+getAll := plan.Task("get-hostname-all", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_all",
+})
+
+// Access per-host results via TaskFuncWithResults.
+printHosts := plan.TaskFuncWithResults("print-hosts",
+ func(
+ _ context.Context,
+ _ *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ r := results.Get("get-hostname-all")
+ for _, hr := range r.HostResults {
+ fmt.Printf(" %s changed=%v\n", hr.Hostname, hr.Changed)
+ }
+ return &orchestrator.Result{Changed: false}, nil
+ },
+)
+printHosts.DependsOn(getAll)
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/broadcast.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/broadcast.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/error-strategy.md b/docs/docs/sidebar/sdk/orchestrator/features/error-strategy.md
new file mode 100644
index 00000000..86c03c21
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/error-strategy.md
@@ -0,0 +1,56 @@
+---
+sidebar_position: 6
+---
+
+# Error Strategies
+
+Control what happens when a task fails.
+
+## Strategies
+
+| Strategy | Behavior |
+| ------------------- | ----------------------------------------------- |
+| `StopAll` (default) | Fail fast, cancel everything |
+| `Continue` | Skip dependents, keep running independent tasks |
+| `Retry(n)` | Retry n times before failing |
+
+## Usage
+
+Set at plan level or override per-task:
+
+```go
+// Plan-level: don't halt on failure.
+plan := orchestrator.NewPlan(client,
+ orchestrator.OnError(orchestrator.Continue),
+)
+
+// Task-level override.
+task.OnError(orchestrator.Retry(3))
+```
+
+With `Continue`, independent tasks keep running when one fails. With `StopAll`,
+the entire plan halts on the first failure.
+
+## Failure Recovery
+
+Use a `When` guard to trigger recovery tasks when an upstream fails:
+
+```go
+alert := plan.TaskFunc("alert",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return &orchestrator.Result{Changed: true}, nil
+ },
+)
+alert.DependsOn(mightFail)
+alert.When(func(results orchestrator.Results) bool {
+ r := results.Get("might-fail")
+ return r != nil && r.Status == orchestrator.StatusFailed
+})
+```
+
+## Examples
+
+See
+[`examples/sdk/orchestrator/features/error-strategy.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/error-strategy.go)
+for a complete working example. For failure-triggered recovery, see
+[Failure Recovery](only-if-failed.md).
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md b/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md
new file mode 100644
index 00000000..816457ca
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md
@@ -0,0 +1,51 @@
+---
+sidebar_position: 9
+---
+
+# File Deployment
+
+Orchestrate a full file deployment workflow: upload a template to the Object
+Store, deploy it to agents with template rendering, then verify status.
+
+## Usage
+
+```go
+// Step 1: Upload the template file.
+upload := plan.TaskFunc("upload-template",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ resp, err := c.File.Upload(ctx, "app.conf.tmpl", "template",
+ bytes.NewReader(tmpl), client.WithForce())
+ if err != nil {
+ return nil, err
+ }
+ return &orchestrator.Result{Changed: resp.Data.Changed}, nil
+ },
+)
+
+// Step 2: Deploy to all agents.
+deploy := plan.Task("deploy-config", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "_all",
+ Params: map[string]any{
+ "object_name": "app.conf.tmpl",
+ "path": "/etc/app/config.yaml",
+ "content_type": "template",
+ "vars": map[string]any{"port": 8080},
+ },
+})
+deploy.DependsOn(upload)
+
+// Step 3: Verify the deployed file.
+verify := plan.Task("verify-status", &orchestrator.Op{
+ Operation: "file.status.get",
+ Target: "_all",
+ Params: map[string]any{"path": "/etc/app/config.yaml"},
+})
+verify.DependsOn(deploy)
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/file-deploy.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/file-deploy.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/guards.md b/docs/docs/sidebar/sdk/orchestrator/features/guards.md
new file mode 100644
index 00000000..09b4090e
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/guards.md
@@ -0,0 +1,64 @@
+---
+sidebar_position: 4
+---
+
+# Guards
+
+Conditional task execution using `When()` predicates and `OnlyIfChanged()`.
+
+## When
+
+`When` takes a predicate that receives completed task results. The task only
+runs if the predicate returns `true`.
+
+```go
+summary := plan.TaskFunc("print-summary",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return &orchestrator.Result{Changed: false}, nil
+ },
+)
+summary.DependsOn(getHostname)
+summary.When(func(results orchestrator.Results) bool {
+ r := results.Get("get-hostname")
+ return r != nil && r.Status == orchestrator.StatusChanged
+})
+```
+
+## WhenWithReason
+
+`WhenWithReason` works like `When` but provides a custom skip reason that is
+passed to the `OnSkip` hook when the guard returns `false`.
+
+```go
+deploy.WhenWithReason(
+ func(results orchestrator.Results) bool {
+ r := results.Get("check-config")
+ return r != nil && r.Status == orchestrator.StatusChanged
+ },
+ "config unchanged, skipping deploy",
+)
+```
+
+Without a reason, skipped tasks report a generic message. Use `WhenWithReason`
+when you want descriptive skip output in your hooks.
+
+## OnlyIfChanged
+
+`OnlyIfChanged` skips the task unless at least one dependency reported a change.
+See [Only If Changed](only-if-changed.md) for details.
+
+```go
+logChange := plan.TaskFunc("log-change",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return &orchestrator.Result{Changed: true}, nil
+ },
+)
+logChange.DependsOn(deploy)
+logChange.OnlyIfChanged()
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/guards.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/guards.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/hooks.md b/docs/docs/sidebar/sdk/orchestrator/features/hooks.md
new file mode 100644
index 00000000..8f65c3cf
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/hooks.md
@@ -0,0 +1,43 @@
+---
+sidebar_position: 5
+---
+
+# Lifecycle Hooks
+
+Register callbacks at every stage of plan execution. The SDK performs no logging
+— hooks are the only output mechanism.
+
+## Hooks
+
+| Hook | Signature |
+| ------------- | ----------------------------------------------- |
+| `BeforePlan` | `func(summary PlanSummary)` |
+| `AfterPlan` | `func(report *Report)` |
+| `BeforeLevel` | `func(level int, tasks []*Task, parallel bool)` |
+| `AfterLevel` | `func(level int, results []TaskResult)` |
+| `BeforeTask` | `func(task *Task)` |
+| `AfterTask` | `func(task *Task, result TaskResult)` |
+| `OnRetry` | `func(task *Task, attempt int, err error)` |
+| `OnSkip` | `func(task *Task, reason string)` |
+
+## Usage
+
+```go
+hooks := orchestrator.Hooks{
+ BeforePlan: func(summary orchestrator.PlanSummary) {
+ fmt.Printf("Plan: %d tasks\n", summary.TotalTasks)
+ },
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+}
+
+plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/hooks.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/hooks.go)
+for a complete working example demonstrating all 8 hooks.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/introspection.md b/docs/docs/sidebar/sdk/orchestrator/features/introspection.md
new file mode 100644
index 00000000..9b9d77e9
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/introspection.md
@@ -0,0 +1,74 @@
+---
+sidebar_position: 11
+---
+
+# Introspection
+
+Inspect and debug orchestration plans before running them.
+
+## Explain
+
+`Explain()` returns a human-readable representation of the execution plan
+showing levels, parallelism, dependencies, and guards.
+
+```go
+plan := orchestrator.NewPlan(client)
+
+health := plan.TaskFunc("check-health", healthFn)
+hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+})
+hostname.DependsOn(health)
+
+fmt.Println(plan.Explain())
+```
+
+Output:
+
+```
+Plan: 2 tasks, 2 levels
+
+Level 0:
+ check-health [fn]
+
+Level 1:
+ get-hostname [op] <- check-health
+```
+
+Tasks are annotated with their type (`fn` for functional tasks, `op` for
+declarative operations), dependency edges (`<-`), and any active guards
+(`only-if-changed`, `when`).
+
+## Levels
+
+`Levels()` returns the levelized DAG — tasks grouped into execution levels where
+all tasks in a level can run concurrently. Returns an error if the plan fails
+validation.
+
+```go
+levels, err := plan.Levels()
+if err != nil {
+ log.Fatal(err)
+}
+
+for i, level := range levels {
+ fmt.Printf("Level %d: %d tasks\n", i, len(level))
+}
+```
+
+## Validate
+
+`Validate()` checks the plan for errors without executing it. It detects
+duplicate task names and dependency cycles.
+
+```go
+if err := plan.Validate(); err != nil {
+ log.Fatal(err) // e.g., "duplicate task name: "foo""
+ // "cycle detected: "a" depends on "b""
+}
+```
+
+`Run()` calls `Validate()` internally, so explicit validation is only needed
+when you want to catch errors before execution — for example, during plan
+construction or in tests.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md b/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md
new file mode 100644
index 00000000..d74ce303
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md
@@ -0,0 +1,53 @@
+---
+sidebar_position: 5
+---
+
+# Only If Changed
+
+Skip a task unless at least one of its dependencies reported a change.
+
+## Usage
+
+Call `OnlyIfChanged()` on a task to make it conditional on upstream changes. If
+every dependency completed with `Changed: false`, the task is skipped and the
+`OnSkip` hook fires with reason `"no dependencies changed"`.
+
+```go
+deploy := plan.Task("deploy-config", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "_any",
+ Params: map[string]any{
+ "object_name": "app.conf",
+ "path": "/etc/app/app.conf",
+ "content_type": "text/plain",
+ },
+})
+
+restart := plan.TaskFunc("restart-service",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ fmt.Println("Restarting service...")
+ return &orchestrator.Result{Changed: true}, nil
+ },
+)
+restart.DependsOn(deploy)
+restart.OnlyIfChanged()
+```
+
+In this example, `restart-service` only runs if the deploy step actually changed
+the file on disk. If the file was already up to date, the restart is skipped.
+
+## How It Works
+
+The orchestrator checks the `Changed` field of every dependency's `Result`. If
+at least one dependency has `Changed: true`, the task runs normally. If all
+dependencies have `Changed: false`, the task is skipped with status
+`StatusSkipped`.
+
+This is equivalent to a `When` guard that checks dependency results, but
+provided as a convenience for the common "only act on changes" pattern.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/only-if-changed.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/only-if-changed.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/only-if-failed.md b/docs/docs/sidebar/sdk/orchestrator/features/only-if-failed.md
new file mode 100644
index 00000000..0732d003
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/only-if-failed.md
@@ -0,0 +1,67 @@
+---
+sidebar_position: 8
+---
+
+# Failure Recovery
+
+Trigger recovery or alerting tasks when an upstream task fails.
+
+## Pattern
+
+Combine the `Continue` error strategy with a `When` guard that checks for
+`StatusFailed` to build failure-triggered recovery flows.
+
+```go
+plan := orchestrator.NewPlan(client,
+ orchestrator.OnError(orchestrator.Continue),
+)
+
+// A task that might fail.
+mightFail := plan.TaskFunc("might-fail",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return nil, fmt.Errorf("simulated failure")
+ },
+)
+mightFail.OnError(orchestrator.Continue)
+
+// Recovery task — only runs if upstream failed.
+alert := plan.TaskFunc("alert",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ fmt.Println("Upstream failed — sending alert!")
+ return &orchestrator.Result{Changed: true}, nil
+ },
+)
+alert.DependsOn(mightFail)
+alert.When(func(results orchestrator.Results) bool {
+ r := results.Get("might-fail")
+ return r != nil && r.Status == orchestrator.StatusFailed
+})
+```
+
+## How It Works
+
+1. The upstream task fails and the `Continue` strategy allows the plan to keep
+ running.
+2. The downstream task's `When` guard receives the completed `Results` map.
+3. The guard checks `r.Status == orchestrator.StatusFailed` — if the upstream
+ succeeded, the guard returns `false` and the recovery task is skipped.
+4. If the upstream failed, the guard returns `true` and the recovery task
+ executes.
+
+Without `Continue`, a failed task with the default `StopAll` strategy would halt
+the entire plan before the recovery task gets a chance to run.
+
+## Status Values
+
+| Status | Meaning |
+| ----------------- | ----------------------------------- |
+| `StatusChanged` | Task succeeded and reported changes |
+| `StatusUnchanged` | Task succeeded with no changes |
+| `StatusSkipped` | Task was skipped by a guard |
+| `StatusFailed` | Task failed with an error |
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/only-if-failed.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/only-if-failed.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/parallel.md b/docs/docs/sidebar/sdk/orchestrator/features/parallel.md
new file mode 100644
index 00000000..5e834f73
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/parallel.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 3
+---
+
+# Parallel Execution
+
+Tasks at the same DAG level run concurrently. Tasks that share a dependency but
+don't depend on each other are automatically parallelized.
+
+## Usage
+
+```go
+health := plan.TaskFunc("check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ return &orchestrator.Result{Changed: false}, err
+ },
+)
+
+// Three tasks at the same level — all depend on health,
+// so the engine runs them in parallel.
+for _, op := range []struct{ name, operation string }{
+ {"get-hostname", "node.hostname.get"},
+ {"get-disk", "node.disk.get"},
+ {"get-memory", "node.memory.get"},
+} {
+ t := plan.Task(op.name, &orchestrator.Op{
+ Operation: op.operation,
+ Target: "_any",
+ })
+ t.DependsOn(health)
+}
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/parallel.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/parallel.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/result-decode.md b/docs/docs/sidebar/sdk/orchestrator/features/result-decode.md
new file mode 100644
index 00000000..91a7a754
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/result-decode.md
@@ -0,0 +1,53 @@
+---
+sidebar_position: 10
+---
+
+# Result Decode
+
+Access task results after plan execution or pass data between tasks.
+
+## Post-Run Access
+
+After `plan.Run()`, inspect results via `Report.Tasks`:
+
+```go
+report, err := plan.Run(context.Background())
+
+for _, r := range report.Tasks {
+ fmt.Printf("Task: %s status=%s changed=%v\n",
+ r.Name, r.Status, r.Changed)
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, " ", " ")
+ fmt.Printf(" data=%s\n", b)
+ }
+}
+```
+
+## TaskFuncWithResults
+
+Use `TaskFuncWithResults` to read upstream task data during execution:
+
+```go
+summary := plan.TaskFuncWithResults("print-summary",
+ func(
+ _ context.Context,
+ _ *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ if r := results.Get("get-hostname"); r != nil {
+ if h, ok := r.Data["hostname"].(string); ok {
+ fmt.Printf("Hostname: %s\n", h)
+ }
+ }
+ return &orchestrator.Result{Changed: false}, nil
+ },
+)
+summary.DependsOn(getHostname)
+```
+
+## Examples
+
+See
+[`examples/sdk/orchestrator/features/result-decode.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/result-decode.go)
+for a complete working example. For inter-task data passing, see
+[Task Functions](task-func.md).
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/retry.md b/docs/docs/sidebar/sdk/orchestrator/features/retry.md
new file mode 100644
index 00000000..7174c7ef
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/retry.md
@@ -0,0 +1,35 @@
+---
+sidebar_position: 7
+---
+
+# Retry
+
+Automatically retry failed tasks before marking them as failed.
+
+## Usage
+
+```go
+getLoad := plan.Task("get-load", &orchestrator.Op{
+ Operation: "node.load.get",
+ Target: "_any",
+})
+getLoad.OnError(orchestrator.Retry(3))
+```
+
+The task will be retried up to 3 times. Use the `OnRetry` hook to observe retry
+attempts:
+
+```go
+hooks := orchestrator.Hooks{
+ OnRetry: func(task *orchestrator.Task, attempt int, err error) {
+ fmt.Printf("[retry] %s attempt=%d error=%q\n",
+ task.Name(), attempt, err)
+ },
+}
+```
+
+## Example
+
+See
+[`examples/sdk/orchestrator/features/retry.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/retry.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/features/task-func.md b/docs/docs/sidebar/sdk/orchestrator/features/task-func.md
new file mode 100644
index 00000000..ab41a0cb
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/features/task-func.md
@@ -0,0 +1,82 @@
+---
+sidebar_position: 3
+---
+
+# Task Functions
+
+Embed custom Go logic in an orchestration plan using `TaskFunc` and
+`TaskFuncWithResults`.
+
+## TaskFunc
+
+`TaskFunc` creates a task that runs arbitrary Go code instead of a declarative
+operation. The function receives a `context.Context` and the OSAPI
+`*client.Client`, and returns a `*Result`.
+
+```go
+health := plan.TaskFunc("check-health",
+ func(
+ ctx context.Context,
+ c *client.Client,
+ ) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health check: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+)
+```
+
+Use `TaskFunc` for logic that doesn't map to a built-in operation — health
+checks, conditional branching, external API calls, or computed results.
+
+## TaskFuncWithResults
+
+`TaskFuncWithResults` works like `TaskFunc` but also receives a `Results` map
+containing outputs from completed upstream tasks. Use it when a task needs data
+produced by a prior step.
+
+```go
+summary := plan.TaskFuncWithResults("print-summary",
+ func(
+ _ context.Context,
+ _ *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ r := results.Get("get-hostname")
+ if r == nil {
+ return &orchestrator.Result{Changed: false}, nil
+ }
+
+ hostname, _ := r.Data["hostname"].(string)
+ fmt.Printf("Hostname: %s\n", hostname)
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+)
+summary.DependsOn(getHostname)
+```
+
+`Results.Get(name)` returns the `*Result` for the named task, or `nil` if the
+task was not found or has not completed.
+
+## When to Use Each
+
+| Type | Use when |
+| --------------------- | ---------------------------------------------- |
+| `Task` (Op) | Calling a built-in OSAPI operation |
+| `TaskFunc` | Running custom logic that doesn't need results |
+| `TaskFuncWithResults` | Running logic that reads upstream task data |
+
+All three types support the same modifiers — `DependsOn`, `When`,
+`OnlyIfChanged`, and `OnError`.
+
+## Examples
+
+See
+[`examples/sdk/orchestrator/features/task-func.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/task-func.go)
+and
+[`examples/sdk/orchestrator/features/task-func-results.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/features/task-func-results.go)
+for complete working examples.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/_category_.json b/docs/docs/sidebar/sdk/orchestrator/operations/_category_.json
new file mode 100644
index 00000000..f4241b96
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Operations",
+ "position": 2
+}
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md b/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md
new file mode 100644
index 00000000..bd7f486e
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md
@@ -0,0 +1,47 @@
+---
+sidebar_position: 2
+---
+
+# command.exec.execute
+
+Execute a command directly on the target node.
+
+## Usage
+
+```go
+task := plan.Task("install-nginx", &orchestrator.Op{
+ Operation: "command.exec.execute",
+ Target: "_all",
+ Params: map[string]any{
+ "command": "apt",
+ "args": []string{"install", "-y", "nginx"},
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| --------- | -------- | -------- | ---------------------- |
+| `command` | string | Yes | The command to execute |
+| `args` | []string | No | Command arguments |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Not idempotent.** Always returns `Changed: true`. Use guards (`OnlyIfChanged`,
+`When`) to control execution.
+
+## Permissions
+
+Requires `command:execute` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/command-exec.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/command-exec.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md b/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md
new file mode 100644
index 00000000..398e5d9e
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md
@@ -0,0 +1,46 @@
+---
+sidebar_position: 3
+---
+
+# command.shell.execute
+
+Execute a shell command string on the target node. The command is passed to
+`/bin/sh -c`.
+
+## Usage
+
+```go
+task := plan.Task("check-disk-space", &orchestrator.Op{
+ Operation: "command.shell.execute",
+ Target: "_any",
+ Params: map[string]any{
+ "command": "df -h / | tail -1 | awk '{print $5}'",
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| --------- | ------ | -------- | ----------------------------- |
+| `command` | string | Yes | The full shell command string |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Not idempotent.** Always returns `Changed: true`. Use guards (`OnlyIfChanged`,
+`When`) to control execution.
+
+## Permissions
+
+Requires `command:execute` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/command-shell.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/command-shell.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md
new file mode 100644
index 00000000..2d98cc6d
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md
@@ -0,0 +1,76 @@
+---
+sidebar_position: 4
+---
+
+# file.deploy.execute
+
+Deploy a file from the Object Store to the target agent's filesystem. Supports
+raw content and Go-template rendering with agent facts and custom variables.
+
+## Usage
+
+```go
+task := plan.Task("deploy-config", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "_all",
+ Params: map[string]any{
+ "object_name": "nginx.conf",
+ "path": "/etc/nginx/nginx.conf",
+ "content_type": "raw",
+ "mode": "0644",
+ "owner": "root",
+ "group": "root",
+ },
+})
+```
+
+### Template Deployment
+
+```go
+task := plan.Task("deploy-template", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "web-01",
+ Params: map[string]any{
+ "object_name": "app.conf.tmpl",
+ "path": "/etc/app/config.yaml",
+ "content_type": "template",
+ "vars": map[string]any{
+ "port": 8080,
+ "debug": false,
+ },
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| -------------- | -------------- | -------- | ---------------------------------------- |
+| `object_name` | string | Yes | Name of the file in the Object Store |
+| `path` | string | Yes | Destination path on the target host |
+| `content_type` | string | Yes | `"raw"` or `"template"` |
+| `mode` | string | No | File permission mode (e.g., `"0644"`) |
+| `owner` | string | No | File owner user |
+| `group` | string | No | File owner group |
+| `vars` | map[string]any | No | Template variables for `"template"` type |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Idempotent.** Compares the SHA-256 of the Object Store content against the
+deployed file. Returns `Changed: true` only if the file was actually written.
+Returns `Changed: false` if the hashes match.
+
+## Permissions
+
+Requires `file:write` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/file-deploy.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/file-deploy.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md
new file mode 100644
index 00000000..f8f5c807
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md
@@ -0,0 +1,45 @@
+---
+sidebar_position: 5
+---
+
+# file.status.get
+
+Check the deployment status of a file on the target agent. Reports whether the
+file is in-sync, drifted, or missing compared to the expected state.
+
+## Usage
+
+```go
+task := plan.Task("check-config", &orchestrator.Op{
+ Operation: "file.status.get",
+ Target: "web-01",
+ Params: map[string]any{
+ "path": "/etc/nginx/nginx.conf",
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| ------ | ------ | -------- | ---------------------------- |
+| `path` | string | Yes | File path to check on target |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `file:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/file-status.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/file-status.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md
new file mode 100644
index 00000000..ec3edc5f
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md
@@ -0,0 +1,46 @@
+---
+sidebar_position: 6
+---
+
+# file.upload
+
+Upload file content to the OSAPI Object Store. Returns the object name that can
+be referenced in subsequent `file.deploy.execute` operations.
+
+## Usage
+
+```go
+task := plan.Task("upload-config", &orchestrator.Op{
+ Operation: "file.upload",
+ Params: map[string]any{
+ "name": "nginx.conf",
+ "content": configBytes,
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| --------- | ------ | -------- | ------------------------------- |
+| `name` | string | Yes | Object name in the Object Store |
+| `content` | []byte | Yes | File content to upload |
+
+## Target
+
+Not applicable. Upload is a server-side operation that does not target an agent.
+
+## Idempotency
+
+**Idempotent.** Uploading the same content with the same name overwrites the
+existing object.
+
+## Permissions
+
+Requires `file:write` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/file-upload.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/file-upload.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md
new file mode 100644
index 00000000..117a9de3
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md
@@ -0,0 +1,44 @@
+---
+sidebar_position: 7
+---
+
+# network.dns.get
+
+Get DNS server configuration for a network interface.
+
+## Usage
+
+```go
+task := plan.Task("get-dns", &orchestrator.Op{
+ Operation: "network.dns.get",
+ Target: "_any",
+ Params: map[string]any{
+ "interface": "eth0",
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| ----------- | ------ | -------- | ---------------------- |
+| `interface` | string | Yes | Network interface name |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `network:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/network-dns-get.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/network-dns-get.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md
new file mode 100644
index 00000000..e3f24291
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md
@@ -0,0 +1,48 @@
+---
+sidebar_position: 8
+---
+
+# network.dns.update
+
+Update DNS servers for a network interface.
+
+## Usage
+
+```go
+task := plan.Task("update-dns", &orchestrator.Op{
+ Operation: "network.dns.update",
+ Target: "_all",
+ Params: map[string]any{
+ "interface": "eth0",
+ "servers": []string{"8.8.8.8", "8.8.4.4"},
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| ----------- | -------- | -------- | ---------------------- |
+| `interface` | string | Yes | Network interface name |
+| `servers` | []string | Yes | DNS server addresses |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Idempotent.** Checks current DNS servers before mutating. Returns
+`Changed: true` only if the servers were actually updated. Returns
+`Changed: false` if the servers already match the desired state.
+
+## Permissions
+
+Requires `network:write` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/network-dns-update.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/network-dns-update.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md
new file mode 100644
index 00000000..5cb9bf82
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md
@@ -0,0 +1,44 @@
+---
+sidebar_position: 9
+---
+
+# network.ping.do
+
+Ping a host and return latency and packet loss statistics.
+
+## Usage
+
+```go
+task := plan.Task("ping-gateway", &orchestrator.Op{
+ Operation: "network.ping.do",
+ Target: "_any",
+ Params: map[string]any{
+ "address": "192.168.1.1",
+ },
+})
+```
+
+## Parameters
+
+| Param | Type | Required | Description |
+| --------- | ------ | -------- | ------------------------------ |
+| `address` | string | Yes | Hostname or IP address to ping |
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `network:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/network-ping.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/network-ping.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md
new file mode 100644
index 00000000..e0932323
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 12
+---
+
+# node.disk.get
+
+Get disk usage statistics for all mounted filesystems.
+
+## Usage
+
+```go
+task := plan.Task("get-disk", &orchestrator.Op{
+ Operation: "node.disk.get",
+ Target: "_any",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-disk.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-disk.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md
new file mode 100644
index 00000000..871d950e
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 10
+---
+
+# node.hostname.get
+
+Get the system hostname and agent labels.
+
+## Usage
+
+```go
+task := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-hostname.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-hostname.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md
new file mode 100644
index 00000000..70e12a2c
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 15
+---
+
+# node.load.get
+
+Get load averages (1-minute, 5-minute, and 15-minute).
+
+## Usage
+
+```go
+task := plan.Task("get-load", &orchestrator.Op{
+ Operation: "node.load.get",
+ Target: "_any",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-load.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-load.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md
new file mode 100644
index 00000000..1551c26e
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 13
+---
+
+# node.memory.get
+
+Get memory statistics including total, available, used, and swap.
+
+## Usage
+
+```go
+task := plan.Task("get-memory", &orchestrator.Op{
+ Operation: "node.memory.get",
+ Target: "_any",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-memory.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-memory.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md
new file mode 100644
index 00000000..e578cd6d
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md
@@ -0,0 +1,40 @@
+---
+sidebar_position: 11
+---
+
+# node.status.get
+
+Get comprehensive node status including hostname, OS information, uptime, disk
+usage, memory statistics, and load averages.
+
+## Usage
+
+```go
+task := plan.Task("get-status", &orchestrator.Op{
+ Operation: "node.status.get",
+ Target: "web-01",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-status.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-status.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md
new file mode 100644
index 00000000..a745de53
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md
@@ -0,0 +1,39 @@
+---
+sidebar_position: 14
+---
+
+# node.uptime.get
+
+Get system uptime information.
+
+## Usage
+
+```go
+task := plan.Task("get-uptime", &orchestrator.Op{
+ Operation: "node.uptime.get",
+ Target: "_any",
+})
+```
+
+## Parameters
+
+None.
+
+## Target
+
+Accepts any valid target: `_any`, `_all`, a hostname, or a label selector
+(`key:value`).
+
+## Idempotency
+
+**Read-only.** Never modifies state. Always returns `Changed: false`.
+
+## Permissions
+
+Requires `node:read` permission.
+
+## Example
+
+See
+[`examples/sdk/orchestrator/operations/node-uptime.go`](https://github.com/retr0h/osapi/blob/main/examples/sdk/orchestrator/operations/node-uptime.go)
+for a complete working example.
diff --git a/docs/docs/sidebar/sdk/orchestrator/orchestrator.md b/docs/docs/sidebar/sdk/orchestrator/orchestrator.md
new file mode 100644
index 00000000..1dd3effe
--- /dev/null
+++ b/docs/docs/sidebar/sdk/orchestrator/orchestrator.md
@@ -0,0 +1,193 @@
+---
+sidebar_position: 1
+---
+
+# Orchestrator
+
+The `orchestrator` package provides DAG-based task orchestration on top of the
+OSAPI SDK client. Define tasks with dependencies and the library handles
+execution order, parallelism, conditional logic, and reporting.
+
+## Quick Start
+
+```go
+import (
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+ "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+client := client.New("http://localhost:8080", "your-jwt-token")
+plan := orchestrator.NewPlan(client)
+
+health := plan.TaskFunc("check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ return &orchestrator.Result{Changed: false}, err
+ },
+)
+
+hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+})
+hostname.DependsOn(health)
+
+report, err := plan.Run(context.Background())
+```
+
+## Operations
+
+Operations are the building blocks of orchestration plans. Each operation maps
+to an OSAPI job type that agents execute.
+
+| Operation | Description | Idempotent | Category |
+| -------------------------------------------------------- | ---------------------- | ---------- | -------- |
+| [`command.exec.execute`](operations/command-exec.md) | Execute a command | No | Command |
+| [`command.shell.execute`](operations/command-shell.md) | Execute a shell string | No | Command |
+| [`file.deploy.execute`](operations/file-deploy.md) | Deploy file to agent | Yes | File |
+| [`file.status.get`](operations/file-status.md) | Check file status | Read-only | File |
+| [`file.upload`](operations/file-upload.md) | Upload to Object Store | Yes | File |
+| [`network.dns.get`](operations/network-dns-get.md) | Get DNS configuration | Read-only | Network |
+| [`network.dns.update`](operations/network-dns-update.md) | Update DNS servers | Yes | Network |
+| [`network.ping.do`](operations/network-ping.md) | Ping a host | Read-only | Network |
+| [`node.hostname.get`](operations/node-hostname.md) | Get system hostname | Read-only | Node |
+| [`node.status.get`](operations/node-status.md) | Get node status | Read-only | Node |
+| [`node.disk.get`](operations/node-disk.md) | Get disk usage | Read-only | Node |
+| [`node.memory.get`](operations/node-memory.md) | Get memory stats | Read-only | Node |
+| [`node.uptime.get`](operations/node-uptime.md) | Get system uptime | Read-only | Node |
+| [`node.load.get`](operations/node-load.md) | Get load averages | Read-only | Node |
+
+### Idempotency
+
+- **Read-only** operations never modify state and always return
+ `Changed: false`.
+- **Idempotent** write operations check current state before mutating and return
+ `Changed: true` only if something actually changed.
+- **Non-idempotent** operations (command exec/shell) always return
+ `Changed: true`. Use guards (`When`, `OnlyIfChanged`) to control when they
+ run.
+
+## Hooks
+
+Register callbacks to control logging and progress at every stage:
+
+```go
+hooks := orchestrator.Hooks{
+ BeforePlan: func(summary orchestrator.PlanSummary) { ... },
+ AfterPlan: func(report *orchestrator.Report) { ... },
+ BeforeLevel: func(level int, tasks []*orchestrator.Task, parallel bool) { ... },
+ AfterLevel: func(level int, results []orchestrator.TaskResult) { ... },
+ BeforeTask: func(task *orchestrator.Task) { ... },
+ AfterTask: func(task *orchestrator.Task, result orchestrator.TaskResult) { ... },
+ OnRetry: func(task *orchestrator.Task, attempt int, err error) { ... },
+ OnSkip: func(task *orchestrator.Task, reason string) { ... },
+}
+
+plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+```
+
+The SDK performs no logging — hooks are the only output mechanism. Consumers
+bring their own formatting.
+
+## Error Strategies
+
+| Strategy | Behavior |
+| ------------------- | ----------------------------------------------- |
+| `StopAll` (default) | Fail fast, cancel everything |
+| `Continue` | Skip dependents, keep running independent tasks |
+| `Retry(n)` | Retry n times before failing |
+
+Strategies can be set at plan level or overridden per-task:
+
+```go
+plan := orchestrator.NewPlan(client, orchestrator.OnError(orchestrator.Continue))
+task.OnError(orchestrator.Retry(3)) // override for this task
+```
+
+## Result Types
+
+### Result
+
+The `Result` struct returned by task functions:
+
+| Field | Type | Description |
+| ------------- | ---------------- | ----------------------------------------- |
+| `Changed` | `bool` | Whether the operation modified state |
+| `Data` | `map[string]any` | Operation-specific response data |
+| `Status` | `Status` | Terminal status (`changed`, `unchanged`) |
+| `HostResults` | `[]HostResult` | Per-host results for broadcast operations |
+
+### TaskResult
+
+The `TaskResult` struct provided to `AfterTask` hooks and in `Report.Tasks`:
+
+| Field | Type | Description |
+| ---------- | ---------------- | ------------------------------------------- |
+| `Name` | `string` | Task name |
+| `Status` | `Status` | Terminal status |
+| `Changed` | `bool` | Whether the operation reported changes |
+| `Duration` | `time.Duration` | Execution time |
+| `Error` | `error` | Error if task failed; nil on success |
+| `Data` | `map[string]any` | Operation response data for post-run access |
+
+### HostResult
+
+Per-host data for broadcast operations (targeting `_all` or label selectors):
+
+| Field | Type | Description |
+| ---------- | ---------------- | ---------------------------------- |
+| `Hostname` | `string` | Agent hostname |
+| `Changed` | `bool` | Whether this host reported changes |
+| `Error` | `string` | Error message; empty on success |
+| `Data` | `map[string]any` | Host-specific response data |
+
+## TaskFuncWithResults
+
+Use `TaskFuncWithResults` when a task needs to read results from prior tasks:
+
+```go
+summarize := plan.TaskFuncWithResults(
+ "summarize",
+ func(
+ ctx context.Context,
+ client *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ r := results.Get("get-hostname")
+ hostname := r.Data["hostname"].(string)
+
+ return &orchestrator.Result{
+ Changed: true,
+ Data: map[string]any{"summary": hostname},
+ }, nil
+ },
+)
+summarize.DependsOn(getHostname)
+```
+
+Unlike `TaskFunc`, the function receives the `Results` map containing completed
+dependency outputs.
+
+## Features
+
+| Feature | Description |
+| --------------------------------------------------- | ------------------------------------ |
+| [Basic Plans](features/basic.md) | Tasks, dependencies, and execution |
+| [Task Functions](features/task-func.md) | Custom Go logic with TaskFunc |
+| [Parallel Execution](features/parallel.md) | Concurrent tasks at the same level |
+| [Guards](features/guards.md) | Conditional execution with When |
+| [Only If Changed](features/only-if-changed.md) | Skip unless dependencies changed |
+| [Lifecycle Hooks](features/hooks.md) | Callbacks at every execution stage |
+| [Error Strategies](features/error-strategy.md) | StopAll, Continue, and Retry |
+| [Failure Recovery](features/only-if-failed.md) | Recovery tasks on upstream failure |
+| [Retry](features/retry.md) | Automatic retry on failure |
+| [Broadcast](features/broadcast.md) | Multi-host targeting and HostResults |
+| [File Deployment](features/file-deploy-workflow.md) | Upload, deploy, and verify workflow |
+| [Result Decode](features/result-decode.md) | Post-run and inter-task data access |
+| [Introspection](features/introspection.md) | Explain, Levels, and Validate |
+
+## Examples
+
+See the
+[orchestrator examples](https://github.com/retr0h/osapi/tree/main/examples/sdk/orchestrator/)
+for runnable demonstrations of each feature.
diff --git a/docs/docs/sidebar/sdk/sdk.md b/docs/docs/sidebar/sdk/sdk.md
index bbbed8fd..57a6a994 100644
--- a/docs/docs/sidebar/sdk/sdk.md
+++ b/docs/docs/sidebar/sdk/sdk.md
@@ -8,4 +8,9 @@ OSAPI provides a Go SDK for programmatic access to the REST API. The SDK
includes a typed client library and a DAG-based orchestrator for composing
multi-step operations.
+| Package | Description |
+| -------------------------------------------- | -------------------------------- |
+| [Client Library](client/client.md) | Typed Go client for the REST API |
+| [Orchestrator](orchestrator/orchestrator.md) | DAG-based task orchestration |
+
diff --git a/docs/docs/sidebar/usage/cli/client/job/status.md b/docs/docs/sidebar/usage/cli/client/job/status.md
deleted file mode 100644
index ad186ae9..00000000
--- a/docs/docs/sidebar/usage/cli/client/job/status.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# Status
-
-Display the job queue status with live updates using a BubbleTea TUI:
-
-```bash
-$ osapi client job status --poll-interval-seconds 5
-
- Jobs Queue Status:
- Total Jobs: 42
- Unprocessed: 5
- Processing: 2
- Completed: 30
- Failed: 5
- Dead Letter Queue: 3
-```
-
-## Flags
-
-| Flag | Description | Default |
-| ------------------------- | ----------------------------------- | ------- |
-| `--poll-interval-seconds` | Interval between polling operations | 30 |
-
-The status view auto-refreshes at the configured interval, showing job counts by
-status.
diff --git a/docs/docusaurus.config.ts b/docs/docusaurus.config.ts
index 4f253632..13f73153 100644
--- a/docs/docusaurus.config.ts
+++ b/docs/docusaurus.config.ts
@@ -154,13 +154,14 @@ const config: Config = {
position: 'left',
items: [
{
- type: 'doc',
- label: 'Overview',
- docId: 'sidebar/sdk/sdk'
+ type: 'html',
+ value:
+ 'Client Library',
+ className: 'dropdown-header'
},
{
type: 'doc',
- label: 'Client',
+ label: 'Overview',
docId: 'sidebar/sdk/client/client'
},
{
@@ -197,6 +198,87 @@ const config: Config = {
type: 'doc',
label: 'Node',
docId: 'sidebar/sdk/client/node'
+ },
+ {
+ type: 'html',
+ value: '
',
+ className: 'dropdown-separator'
+ },
+ {
+ type: 'html',
+ value:
+ 'Orchestrator',
+ className: 'dropdown-header'
+ },
+ {
+ type: 'doc',
+ label: 'Overview',
+ docId: 'sidebar/sdk/orchestrator/orchestrator'
+ },
+ {
+ type: 'doc',
+ label: 'Basic',
+ docId: 'sidebar/sdk/orchestrator/features/basic'
+ },
+ {
+ type: 'doc',
+ label: 'Task Functions',
+ docId: 'sidebar/sdk/orchestrator/features/task-func'
+ },
+ {
+ type: 'doc',
+ label: 'Parallel',
+ docId: 'sidebar/sdk/orchestrator/features/parallel'
+ },
+ {
+ type: 'doc',
+ label: 'Guards',
+ docId: 'sidebar/sdk/orchestrator/features/guards'
+ },
+ {
+ type: 'doc',
+ label: 'Only If Changed',
+ docId: 'sidebar/sdk/orchestrator/features/only-if-changed'
+ },
+ {
+ type: 'doc',
+ label: 'Hooks',
+ docId: 'sidebar/sdk/orchestrator/features/hooks'
+ },
+ {
+ type: 'doc',
+ label: 'Error Strategies',
+ docId: 'sidebar/sdk/orchestrator/features/error-strategy'
+ },
+ {
+ type: 'doc',
+ label: 'Failure Recovery',
+ docId: 'sidebar/sdk/orchestrator/features/only-if-failed'
+ },
+ {
+ type: 'doc',
+ label: 'Retry',
+ docId: 'sidebar/sdk/orchestrator/features/retry'
+ },
+ {
+ type: 'doc',
+ label: 'Broadcast',
+ docId: 'sidebar/sdk/orchestrator/features/broadcast'
+ },
+ {
+ type: 'doc',
+ label: 'File Deploy',
+ docId: 'sidebar/sdk/orchestrator/features/file-deploy-workflow'
+ },
+ {
+ type: 'doc',
+ label: 'Result Decode',
+ docId: 'sidebar/sdk/orchestrator/features/result-decode'
+ },
+ {
+ type: 'doc',
+ label: 'Introspection',
+ docId: 'sidebar/sdk/orchestrator/features/introspection'
}
]
},
diff --git a/examples/sdk/osapi/agent.go b/examples/sdk/client/agent.go
similarity index 97%
rename from examples/sdk/osapi/agent.go
rename to examples/sdk/client/agent.go
index 0022f73d..6db50351 100644
--- a/examples/sdk/osapi/agent.go
+++ b/examples/sdk/client/agent.go
@@ -33,7 +33,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -47,7 +47,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
// List all active agents.
diff --git a/examples/sdk/osapi/audit.go b/examples/sdk/client/audit.go
similarity index 96%
rename from examples/sdk/osapi/audit.go
rename to examples/sdk/client/audit.go
index 4b41347e..737b6b12 100644
--- a/examples/sdk/osapi/audit.go
+++ b/examples/sdk/client/audit.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -46,7 +46,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
// List recent audit entries.
diff --git a/examples/sdk/osapi/command.go b/examples/sdk/client/command.go
similarity index 92%
rename from examples/sdk/osapi/command.go
rename to examples/sdk/client/command.go
index 7a2df56c..35e78d78 100644
--- a/examples/sdk/osapi/command.go
+++ b/examples/sdk/client/command.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -46,12 +46,12 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
target := "_any"
// Direct exec — runs a binary with arguments.
- exec, err := client.Node.Exec(ctx, osapi.ExecRequest{
+ exec, err := client.Node.Exec(ctx, client.ExecRequest{
Target: target,
Command: "uptime",
})
@@ -66,7 +66,7 @@ func main() {
}
// Shell — interpreted by /bin/sh, supports pipes and redirection.
- shell, err := client.Node.Shell(ctx, osapi.ShellRequest{
+ shell, err := client.Node.Shell(ctx, client.ShellRequest{
Target: target,
Command: "uname -a",
})
diff --git a/examples/sdk/osapi/file.go b/examples/sdk/client/file.go
similarity index 95%
rename from examples/sdk/osapi/file.go
rename to examples/sdk/client/file.go
index 67dba838..680f00b4 100644
--- a/examples/sdk/osapi/file.go
+++ b/examples/sdk/client/file.go
@@ -34,7 +34,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -48,7 +48,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
// Upload a raw file to the Object Store.
@@ -80,7 +80,7 @@ func main() {
"app.conf",
"raw",
bytes.NewReader(content),
- osapi.WithForce(),
+ client.WithForce(),
)
if err != nil {
log.Fatalf("force upload: %v", err)
@@ -110,7 +110,7 @@ func main() {
meta.Data.Name, meta.Data.SHA256, meta.Data.Size)
// Deploy the file to an agent.
- deploy, err := client.Node.FileDeploy(ctx, osapi.FileDeployOpts{
+ deploy, err := client.Node.FileDeploy(ctx, client.FileDeployOpts{
ObjectName: "app.conf",
Path: "/tmp/app.conf",
ContentType: "raw",
diff --git a/examples/sdk/osapi/go.mod b/examples/sdk/client/go.mod
similarity index 92%
rename from examples/sdk/osapi/go.mod
rename to examples/sdk/client/go.mod
index ade9f14b..085b0c13 100644
--- a/examples/sdk/osapi/go.mod
+++ b/examples/sdk/client/go.mod
@@ -1,4 +1,4 @@
-module github.com/retr0h/osapi/examples/sdk/osapi
+module github.com/retr0h/osapi/examples/sdk/client
go 1.25.0
diff --git a/examples/sdk/osapi/go.sum b/examples/sdk/client/go.sum
similarity index 100%
rename from examples/sdk/osapi/go.sum
rename to examples/sdk/client/go.sum
diff --git a/examples/sdk/osapi/health.go b/examples/sdk/client/health.go
similarity index 96%
rename from examples/sdk/osapi/health.go
rename to examples/sdk/client/health.go
index c43c0a64..23429266 100644
--- a/examples/sdk/osapi/health.go
+++ b/examples/sdk/client/health.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -46,7 +46,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
// Liveness — is the API process running?
diff --git a/examples/sdk/osapi/job.go b/examples/sdk/client/job.go
similarity index 87%
rename from examples/sdk/osapi/job.go
rename to examples/sdk/client/job.go
index 517821df..0274eefd 100644
--- a/examples/sdk/osapi/job.go
+++ b/examples/sdk/client/job.go
@@ -33,7 +33,7 @@ import (
"os"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -47,7 +47,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
// Create a job.
@@ -72,7 +72,7 @@ func main() {
fmt.Printf("Job %s: status=%s\n", job.Data.ID, job.Data.Status)
// List recent jobs.
- list, err := client.Job.List(ctx, osapi.ListParams{Limit: 5})
+ list, err := client.Job.List(ctx, client.ListParams{Limit: 5})
if err != nil {
log.Fatalf("list jobs: %v", err)
}
@@ -83,12 +83,4 @@ func main() {
fmt.Printf(" %s status=%s op=%v\n",
j.ID, j.Status, j.Operation)
}
-
- // Queue statistics.
- stats, err := client.Job.QueueStats(ctx)
- if err != nil {
- log.Fatalf("queue stats: %v", err)
- }
-
- fmt.Printf("\nQueue: %d total jobs\n", stats.Data.TotalJobs)
}
diff --git a/examples/sdk/osapi/metrics.go b/examples/sdk/client/metrics.go
similarity index 95%
rename from examples/sdk/osapi/metrics.go
rename to examples/sdk/client/metrics.go
index 24d3bea8..2ec60d66 100644
--- a/examples/sdk/osapi/metrics.go
+++ b/examples/sdk/client/metrics.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -46,7 +46,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
text, err := client.Metrics.Get(ctx)
diff --git a/examples/sdk/osapi/network.go b/examples/sdk/client/network.go
similarity index 96%
rename from examples/sdk/osapi/network.go
rename to examples/sdk/client/network.go
index e46ce0eb..cd0d7f9e 100644
--- a/examples/sdk/osapi/network.go
+++ b/examples/sdk/client/network.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -51,7 +51,7 @@ func main() {
iface = "eth0"
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
target := "_any"
diff --git a/examples/sdk/osapi/node.go b/examples/sdk/client/node.go
similarity index 97%
rename from examples/sdk/osapi/node.go
rename to examples/sdk/client/node.go
index 2fd58012..3cd64335 100644
--- a/examples/sdk/osapi/node.go
+++ b/examples/sdk/client/node.go
@@ -32,7 +32,7 @@ import (
"log"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
func main() {
@@ -46,7 +46,7 @@ func main() {
log.Fatal("OSAPI_TOKEN is required")
}
- client := osapi.New(url, token)
+ client := client.New(url, token)
ctx := context.Background()
target := "_any"
diff --git a/examples/sdk/orchestrator/features/basic.go b/examples/sdk/orchestrator/features/basic.go
new file mode 100644
index 00000000..f3b884be
--- /dev/null
+++ b/examples/sdk/orchestrator/features/basic.go
@@ -0,0 +1,91 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates the simplest orchestrator plan: a health
+// check followed by a hostname query using Op tasks with DependsOn.
+//
+// DAG:
+//
+// check-health
+// └── get-hostname
+//
+// Run with: OSAPI_TOKEN="" go run basic.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ BeforeTask: func(task *orchestrator.Task) {
+ fmt.Printf(" [start] %s\n", task.Name())
+ },
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ health := plan.TaskFunc(
+ "check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+ hostname.DependsOn(health)
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/broadcast.go b/examples/sdk/orchestrator/features/broadcast.go
new file mode 100644
index 00000000..3f7417d0
--- /dev/null
+++ b/examples/sdk/orchestrator/features/broadcast.go
@@ -0,0 +1,106 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates broadcast targeting with _all. The
+// operation is sent to every registered agent and per-host results
+// are available via HostResults.
+//
+// DAG:
+//
+// get-hostname-all (_all broadcast)
+// └── print-hosts (reads HostResults)
+//
+// Run with: OSAPI_TOKEN="" go run broadcast.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+
+ // Show per-host results for broadcast operations.
+ for _, hr := range result.HostResults {
+ fmt.Printf(" host=%s changed=%v\n",
+ hr.Hostname, hr.Changed)
+ }
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ // Target _all: delivered to every registered agent.
+ getAll := plan.Task("get-hostname-all", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_all",
+ })
+
+ // Access per-host results from broadcast tasks.
+ printHosts := plan.TaskFuncWithResults(
+ "print-hosts",
+ func(
+ _ context.Context,
+ _ *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ r := results.Get("get-hostname-all")
+ if r == nil {
+ return &orchestrator.Result{Changed: false}, nil
+ }
+
+ fmt.Printf("\n Hosts responded: %d\n", len(r.HostResults))
+ for _, hr := range r.HostResults {
+ fmt.Printf(" %s\n", hr.Hostname)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+ printHosts.DependsOn(getAll)
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/error-strategy.go b/examples/sdk/orchestrator/features/error-strategy.go
new file mode 100644
index 00000000..508d94df
--- /dev/null
+++ b/examples/sdk/orchestrator/features/error-strategy.go
@@ -0,0 +1,95 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates error strategies: Continue vs StopAll.
+//
+// With Continue, independent tasks keep running when one fails.
+// With StopAll (default), the entire plan halts on the first failure.
+//
+// DAG:
+//
+// might-fail (continue)
+// get-hostname (independent, runs despite failure)
+//
+// Run with: OSAPI_TOKEN="" go run error-strategy.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ status := string(result.Status)
+ if result.Error != nil {
+ status += fmt.Sprintf(" (%s)", result.Error)
+ }
+
+ fmt.Printf(" [%s] %s\n", status, result.Name)
+ },
+ }
+
+ // Plan-level Continue: don't halt on failure.
+ plan := orchestrator.NewPlan(
+ client,
+ orchestrator.WithHooks(hooks),
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ // This task fails, but Continue lets the plan proceed.
+ plan.TaskFunc(
+ "might-fail",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return nil, fmt.Errorf("simulated failure")
+ },
+ )
+
+ // Independent task — runs despite the failure above.
+ plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/file-deploy-workflow.go b/examples/sdk/orchestrator/features/file-deploy-workflow.go
new file mode 100644
index 00000000..8310be9a
--- /dev/null
+++ b/examples/sdk/orchestrator/features/file-deploy-workflow.go
@@ -0,0 +1,128 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates file deployment orchestration: upload a
+// template file, deploy to all agents with template rendering, then
+// verify status.
+//
+// DAG:
+//
+// upload-template
+// └── deploy-config
+// └── verify-status
+//
+// Run with: OSAPI_TOKEN="" go run file-deploy-workflow.go
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ BeforeTask: func(task *orchestrator.Task) {
+ fmt.Printf(" [start] %s\n", task.Name())
+ },
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ // Step 1: Upload the template file to Object Store.
+ upload := plan.TaskFunc(
+ "upload-template",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ tmpl := []byte(`# Generated for {{ .Hostname }}
+listen_address = {{ .Vars.listen_address }}
+workers = {{ .Facts.cpu_count }}
+`)
+ resp, err := c.File.Upload(
+ ctx,
+ "app.conf.tmpl",
+ "template",
+ bytes.NewReader(tmpl),
+ client.WithForce(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("upload: %w", err)
+ }
+
+ fmt.Printf(" uploaded %s (sha256=%s changed=%v)\n",
+ resp.Data.Name, resp.Data.SHA256, resp.Data.Changed)
+
+ return &orchestrator.Result{Changed: resp.Data.Changed}, nil
+ },
+ )
+
+ // Step 2: Deploy the template to all agents.
+ deploy := plan.Task("deploy-config", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "_all",
+ Params: map[string]any{
+ "object_name": "app.conf.tmpl",
+ "path": "/tmp/app.conf",
+ "content_type": "template",
+ "mode": "0644",
+ "vars": map[string]any{
+ "listen_address": "0.0.0.0:8080",
+ },
+ },
+ })
+ deploy.DependsOn(upload)
+
+ // Step 3: Verify the deployed file is in-sync.
+ verify := plan.Task("verify-status", &orchestrator.Op{
+ Operation: "file.status.get",
+ Target: "_all",
+ Params: map[string]any{
+ "path": "/tmp/app.conf",
+ },
+ })
+ verify.DependsOn(deploy)
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/go.mod b/examples/sdk/orchestrator/features/go.mod
new file mode 100644
index 00000000..d572f38e
--- /dev/null
+++ b/examples/sdk/orchestrator/features/go.mod
@@ -0,0 +1,20 @@
+module github.com/retr0h/osapi/examples/sdk/orchestrator/features
+
+go 1.25.0
+
+replace github.com/retr0h/osapi => ../../../../
+
+require github.com/retr0h/osapi v0.0.0
+
+require (
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/oapi-codegen/runtime v1.2.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.41.0 // indirect
+)
diff --git a/examples/sdk/orchestrator/features/go.sum b/examples/sdk/orchestrator/features/go.sum
new file mode 100644
index 00000000..96b22a61
--- /dev/null
+++ b/examples/sdk/orchestrator/features/go.sum
@@ -0,0 +1,39 @@
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4=
+github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/sdk/orchestrator/features/guards.go b/examples/sdk/orchestrator/features/guards.go
new file mode 100644
index 00000000..a40ee6a9
--- /dev/null
+++ b/examples/sdk/orchestrator/features/guards.go
@@ -0,0 +1,109 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates When() guard predicates for conditional
+// task execution. The summary task only runs if the hostname step
+// succeeded.
+//
+// DAG:
+//
+// check-health
+// └── get-hostname
+// └── print-summary (when: hostname changed)
+//
+// Run with: OSAPI_TOKEN="" go run guards.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s\n", result.Status, result.Name)
+ },
+ OnSkip: func(task *orchestrator.Task, reason string) {
+ fmt.Printf(" [skip] %s reason=%q\n", task.Name(), reason)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ health := plan.TaskFunc(
+ "check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ getHostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+ getHostname.DependsOn(health)
+
+ summary := plan.TaskFunc(
+ "print-summary",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ fmt.Println("\n Hostname was retrieved successfully!")
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+ summary.DependsOn(getHostname)
+
+ // Guard: only run if get-hostname reported StatusChanged.
+ summary.When(func(results orchestrator.Results) bool {
+ r := results.Get("get-hostname")
+
+ return r != nil && r.Status == orchestrator.StatusChanged
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/hooks.go b/examples/sdk/orchestrator/features/hooks.go
new file mode 100644
index 00000000..4e46938e
--- /dev/null
+++ b/examples/sdk/orchestrator/features/hooks.go
@@ -0,0 +1,143 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates all 8 lifecycle hooks: BeforePlan,
+// AfterPlan, BeforeLevel, AfterLevel, BeforeTask, AfterTask,
+// OnRetry, and OnSkip.
+//
+// DAG:
+//
+// check-health
+// ├── get-hostname
+// └── get-disk
+//
+// Run with: OSAPI_TOKEN="" go run hooks.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ BeforePlan: func(summary orchestrator.PlanSummary) {
+ fmt.Printf("=== Plan: %d tasks, %d steps ===\n",
+ summary.TotalTasks, len(summary.Steps))
+ },
+ AfterPlan: func(report *orchestrator.Report) {
+ fmt.Printf("\n=== Done: %s in %s ===\n",
+ report.Summary(), report.Duration)
+ },
+ BeforeLevel: func(level int, tasks []*orchestrator.Task, parallel bool) {
+ names := make([]string, len(tasks))
+ for i, t := range tasks {
+ names[i] = t.Name()
+ }
+
+ mode := "sequential"
+ if parallel {
+ mode = "parallel"
+ }
+
+ fmt.Printf("\n>>> Step %d (%s): %s\n",
+ level+1, mode, strings.Join(names, ", "))
+ },
+ AfterLevel: func(level int, results []orchestrator.TaskResult) {
+ changed := 0
+ for _, r := range results {
+ if r.Changed {
+ changed++
+ }
+ }
+
+ fmt.Printf("<<< Step %d: %d/%d changed\n",
+ level+1, changed, len(results))
+ },
+ BeforeTask: func(task *orchestrator.Task) {
+ if op := task.Operation(); op != nil {
+ fmt.Printf(" [start] %s op=%s\n",
+ task.Name(), op.Operation)
+ } else {
+ fmt.Printf(" [start] %s (func)\n", task.Name())
+ }
+ },
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v duration=%s\n",
+ result.Status, result.Name, result.Changed, result.Duration)
+ },
+ OnRetry: func(task *orchestrator.Task, attempt int, err error) {
+ fmt.Printf(" [retry] %s attempt=%d err=%q\n",
+ task.Name(), attempt, err)
+ },
+ OnSkip: func(task *orchestrator.Task, reason string) {
+ fmt.Printf(" [skip] %s reason=%q\n", task.Name(), reason)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ health := plan.TaskFunc(
+ "check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+ hostname.DependsOn(health)
+
+ disk := plan.Task("get-disk", &orchestrator.Op{
+ Operation: "node.disk.get",
+ Target: "_any",
+ })
+ disk.DependsOn(health)
+
+ _, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+}
diff --git a/examples/sdk/orchestrator/features/only-if-changed.go b/examples/sdk/orchestrator/features/only-if-changed.go
new file mode 100644
index 00000000..08525ecb
--- /dev/null
+++ b/examples/sdk/orchestrator/features/only-if-changed.go
@@ -0,0 +1,89 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates OnlyIfChanged() — a task that is skipped
+// unless at least one dependency reported a change.
+//
+// DAG:
+//
+// get-hostname
+// └── log-change (only-if-changed)
+//
+// Run with: OSAPI_TOKEN="" go run only-if-changed.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ OnSkip: func(task *orchestrator.Task, reason string) {
+ fmt.Printf(" [skip] %s reason=%q\n", task.Name(), reason)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ getHostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+
+ logChange := plan.TaskFunc(
+ "log-change",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ fmt.Println("\n Dependencies changed — logging event.")
+
+ return &orchestrator.Result{Changed: true}, nil
+ },
+ )
+ logChange.DependsOn(getHostname)
+ logChange.OnlyIfChanged()
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/only-if-failed.go b/examples/sdk/orchestrator/features/only-if-failed.go
new file mode 100644
index 00000000..2087d629
--- /dev/null
+++ b/examples/sdk/orchestrator/features/only-if-failed.go
@@ -0,0 +1,107 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates failure-triggered recovery using a When
+// guard that checks for StatusFailed. The alert task only runs when
+// the upstream task has failed.
+//
+// DAG:
+//
+// might-fail (continue on error)
+// └── alert (when: might-fail == StatusFailed)
+//
+// Run with: OSAPI_TOKEN="" go run only-if-failed.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ status := string(result.Status)
+ if result.Error != nil {
+ status += fmt.Sprintf(" (%s)", result.Error)
+ }
+
+ fmt.Printf(" [%s] %s\n", status, result.Name)
+ },
+ OnSkip: func(task *orchestrator.Task, reason string) {
+ fmt.Printf(" [skip] %s reason=%q\n", task.Name(), reason)
+ },
+ }
+
+ plan := orchestrator.NewPlan(
+ client,
+ orchestrator.WithHooks(hooks),
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ // A task that intentionally fails.
+ mightFail := plan.TaskFunc(
+ "might-fail",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ return nil, fmt.Errorf("simulated failure")
+ },
+ )
+ mightFail.OnError(orchestrator.Continue)
+
+ // Recovery task — only runs if upstream failed.
+ alert := plan.TaskFunc(
+ "alert",
+ func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) {
+ fmt.Println("\n Upstream failed — sending alert!")
+
+ return &orchestrator.Result{Changed: true}, nil
+ },
+ )
+ alert.DependsOn(mightFail)
+ alert.When(func(results orchestrator.Results) bool {
+ r := results.Get("might-fail")
+
+ return r != nil && r.Status == orchestrator.StatusFailed
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/parallel.go b/examples/sdk/orchestrator/features/parallel.go
new file mode 100644
index 00000000..3f3ca6a2
--- /dev/null
+++ b/examples/sdk/orchestrator/features/parallel.go
@@ -0,0 +1,112 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates parallel task execution. Tasks at the same
+// DAG level run concurrently.
+//
+// DAG:
+//
+// check-health
+// ├── get-hostname
+// ├── get-disk
+// └── get-memory
+//
+// Run with: OSAPI_TOKEN="" go run parallel.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ BeforeLevel: func(level int, tasks []*orchestrator.Task, parallel bool) {
+ names := make([]string, len(tasks))
+ for i, t := range tasks {
+ names[i] = t.Name()
+ }
+
+ mode := "sequential"
+ if parallel {
+ mode = "parallel"
+ }
+
+ fmt.Printf("Step %d (%s): %s\n", level+1, mode, strings.Join(names, ", "))
+ },
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s duration=%s\n",
+ result.Status, result.Name, result.Duration)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ health := plan.TaskFunc(
+ "check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ // Three tasks at the same level — all depend on health,
+ // so the engine runs them in parallel.
+ for _, op := range []struct{ name, operation string }{
+ {"get-hostname", "node.hostname.get"},
+ {"get-disk", "node.disk.get"},
+ {"get-memory", "node.memory.get"},
+ } {
+ t := plan.Task(op.name, &orchestrator.Op{
+ Operation: op.operation,
+ Target: "_any",
+ })
+ t.DependsOn(health)
+ }
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/result-decode.go b/examples/sdk/orchestrator/features/result-decode.go
new file mode 100644
index 00000000..c4961f77
--- /dev/null
+++ b/examples/sdk/orchestrator/features/result-decode.go
@@ -0,0 +1,79 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates post-execution result access via
+// Report.Tasks[].Data. After the plan completes, task results
+// can be inspected programmatically.
+//
+// DAG:
+//
+// get-hostname
+//
+// Run with: OSAPI_TOKEN="" go run result-decode.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+ plan := orchestrator.NewPlan(client)
+
+ plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("%s in %s\n\n", report.Summary(), report.Duration)
+
+ // Inspect task results after execution.
+ for _, r := range report.Tasks {
+ fmt.Printf("Task: %s status=%s changed=%v\n",
+ r.Name, r.Status, r.Changed)
+
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, " ", " ")
+ fmt.Printf(" data=%s\n", b)
+ }
+ }
+}
diff --git a/examples/sdk/orchestrator/features/retry.go b/examples/sdk/orchestrator/features/retry.go
new file mode 100644
index 00000000..f1068e12
--- /dev/null
+++ b/examples/sdk/orchestrator/features/retry.go
@@ -0,0 +1,77 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates Retry(n) for automatic retry on failure.
+//
+// DAG:
+//
+// get-load [retry:3]
+//
+// Run with: OSAPI_TOKEN="" go run retry.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s\n", result.Status, result.Name)
+ },
+ OnRetry: func(task *orchestrator.Task, attempt int, err error) {
+ fmt.Printf(" [retry] %s attempt=%d error=%q\n",
+ task.Name(), attempt, err)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ getLoad := plan.Task("get-load", &orchestrator.Op{
+ Operation: "node.load.get",
+ Target: "_any",
+ })
+ getLoad.OnError(orchestrator.Retry(3))
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/task-func-results.go b/examples/sdk/orchestrator/features/task-func-results.go
new file mode 100644
index 00000000..cd6f354e
--- /dev/null
+++ b/examples/sdk/orchestrator/features/task-func-results.go
@@ -0,0 +1,108 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates TaskFuncWithResults for reading data from
+// previously completed tasks. The summary step reads hostname data
+// set by a prior Op task.
+//
+// DAG:
+//
+// check-health
+// └── get-hostname
+// └── print-summary (reads get-hostname data)
+//
+// Run with: OSAPI_TOKEN="" go run task-func-results.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s\n", result.Status, result.Name)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ health := plan.TaskFunc(
+ "check-health",
+ func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ getHostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+ getHostname.DependsOn(health)
+
+ // TaskFuncWithResults: access completed task data via Results.Get().
+ summary := plan.TaskFuncWithResults(
+ "print-summary",
+ func(
+ _ context.Context,
+ _ *client.Client,
+ results orchestrator.Results,
+ ) (*orchestrator.Result, error) {
+ if r := results.Get("get-hostname"); r != nil {
+ if h, ok := r.Data["hostname"].(string); ok {
+ fmt.Printf("\n Hostname: %s\n", h)
+ }
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+ summary.DependsOn(getHostname)
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/features/task-func.go b/examples/sdk/orchestrator/features/task-func.go
new file mode 100644
index 00000000..1bebf5d3
--- /dev/null
+++ b/examples/sdk/orchestrator/features/task-func.go
@@ -0,0 +1,92 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+// Package main demonstrates TaskFunc for embedding custom Go logic
+// in an orchestration plan.
+//
+// DAG:
+//
+// check-health (TaskFunc)
+// └── get-hostname (Op)
+//
+// Run with: OSAPI_TOKEN="" go run task-func.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ client := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf(" [%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks))
+
+ // TaskFunc: run arbitrary Go code as a plan step.
+ health := plan.TaskFunc(
+ "check-health",
+ func(
+ ctx context.Context,
+ c *client.Client,
+ ) (*orchestrator.Result, error) {
+ _, err := c.Health.Liveness(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("health check: %w", err)
+ }
+
+ return &orchestrator.Result{Changed: false}, nil
+ },
+ )
+
+ hostname := plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+ hostname.DependsOn(health)
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/command-exec.go b/examples/sdk/orchestrator/operations/command-exec.go
new file mode 100644
index 00000000..3975f76e
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/command-exec.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the command.exec.execute operation, which
+// runs a command directly on the target node.
+//
+// Run with: OSAPI_TOKEN="" go run command-exec.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("exec-uptime", &orchestrator.Op{
+ Operation: "command.exec.execute",
+ Target: "_any",
+ Params: map[string]any{
+ "command": "uptime",
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/command-shell.go b/examples/sdk/orchestrator/operations/command-shell.go
new file mode 100644
index 00000000..937e7dd4
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/command-shell.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the command.shell.execute operation, which
+// runs a shell string on the target node via /bin/sh -c.
+//
+// Run with: OSAPI_TOKEN="" go run command-shell.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("shell-echo", &orchestrator.Op{
+ Operation: "command.shell.execute",
+ Target: "_any",
+ Params: map[string]any{
+ "command": "echo hello from $(hostname)",
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/file-deploy.go b/examples/sdk/orchestrator/operations/file-deploy.go
new file mode 100644
index 00000000..f26b707c
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/file-deploy.go
@@ -0,0 +1,86 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the file.deploy.execute operation, which
+// deploys a file from the Object Store to a target node.
+//
+// Run with: OSAPI_TOKEN="" go run file-deploy.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("deploy-config", &orchestrator.Op{
+ Operation: "file.deploy.execute",
+ Target: "_all",
+ Params: map[string]any{
+ "object_name": "app.conf",
+ "path": "/etc/app/config.yaml",
+ "content_type": "static",
+ "mode": "0644",
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/file-status.go b/examples/sdk/orchestrator/operations/file-status.go
new file mode 100644
index 00000000..d73f56f8
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/file-status.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the file.status.get operation, which
+// checks the status of a deployed file on the target node.
+//
+// Run with: OSAPI_TOKEN="" go run file-status.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("check-file", &orchestrator.Op{
+ Operation: "file.status.get",
+ Target: "_any",
+ Params: map[string]any{
+ "path": "/etc/app/config.yaml",
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/file-upload.go b/examples/sdk/orchestrator/operations/file-upload.go
new file mode 100644
index 00000000..d831f2d8
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/file-upload.go
@@ -0,0 +1,89 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the file.upload operation, which uploads
+// a file to the Object Store via a TaskFunc.
+//
+// Run with: OSAPI_TOKEN="" go run file-upload.go
+package main
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.TaskFunc(
+ "upload-config",
+ func(ctx context.Context, cc *client.Client) (*orchestrator.Result, error) {
+ content := []byte("server.port = 8080\nserver.host = 0.0.0.0\n")
+ resp, err := cc.File.Upload(
+ ctx,
+ "app.conf",
+ "static",
+ bytes.NewReader(content),
+ client.WithForce(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("upload: %w", err)
+ }
+
+ fmt.Printf(" uploaded %s (sha256=%s)\n", resp.Data.Name, resp.Data.SHA256)
+
+ return &orchestrator.Result{Changed: resp.Data.Changed}, nil
+ },
+ )
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/go.mod b/examples/sdk/orchestrator/operations/go.mod
new file mode 100644
index 00000000..b36b0e77
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/go.mod
@@ -0,0 +1,20 @@
+module github.com/retr0h/osapi/examples/sdk/orchestrator/operations
+
+go 1.25.0
+
+replace github.com/retr0h/osapi => ../../../../
+
+require github.com/retr0h/osapi v0.0.0
+
+require (
+ github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/oapi-codegen/runtime v1.2.0 // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel v1.41.0 // indirect
+ go.opentelemetry.io/otel/metric v1.41.0 // indirect
+ go.opentelemetry.io/otel/trace v1.41.0 // indirect
+)
diff --git a/examples/sdk/orchestrator/operations/go.sum b/examples/sdk/orchestrator/operations/go.sum
new file mode 100644
index 00000000..96b22a61
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/go.sum
@@ -0,0 +1,39 @@
+github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
+github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
+github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
+github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
+github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
+github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
+github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
+github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+LpmzwHMP4=
+github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
+go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
+go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
+go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
+go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
+go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
+go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
+go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/examples/sdk/orchestrator/operations/network-dns-get.go b/examples/sdk/orchestrator/operations/network-dns-get.go
new file mode 100644
index 00000000..d526ca99
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/network-dns-get.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the network.dns.get operation, which
+// retrieves DNS configuration for a network interface.
+//
+// Run with: OSAPI_TOKEN="" go run network-dns-get.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-dns", &orchestrator.Op{
+ Operation: "network.dns.get",
+ Target: "_any",
+ Params: map[string]any{
+ "interface_name": "eth0",
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/network-dns-update.go b/examples/sdk/orchestrator/operations/network-dns-update.go
new file mode 100644
index 00000000..aa34e0f8
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/network-dns-update.go
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the network.dns.update operation, which
+// updates DNS servers for a network interface.
+//
+// Run with: OSAPI_TOKEN="" go run network-dns-update.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("update-dns", &orchestrator.Op{
+ Operation: "network.dns.update",
+ Target: "_any",
+ Params: map[string]any{
+ "interface_name": "eth0",
+ "addresses": []string{"8.8.8.8", "8.8.4.4"},
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/network-ping.go b/examples/sdk/orchestrator/operations/network-ping.go
new file mode 100644
index 00000000..5e67d704
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/network-ping.go
@@ -0,0 +1,84 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the network.ping.do operation, which
+// pings a host from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run network-ping.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("ping-host", &orchestrator.Op{
+ Operation: "network.ping.do",
+ Target: "_any",
+ Params: map[string]any{
+ "address": "8.8.8.8",
+ "count": 3,
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-disk.go b/examples/sdk/orchestrator/operations/node-disk.go
new file mode 100644
index 00000000..e0896128
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-disk.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.disk.get operation, which
+// retrieves disk usage statistics from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run node-disk.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-disk", &orchestrator.Op{
+ Operation: "node.disk.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-hostname.go b/examples/sdk/orchestrator/operations/node-hostname.go
new file mode 100644
index 00000000..32399396
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-hostname.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.hostname.get operation, which
+// retrieves the system hostname from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run node-hostname.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-hostname", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-load.go b/examples/sdk/orchestrator/operations/node-load.go
new file mode 100644
index 00000000..2d049329
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-load.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.load.get operation, which
+// retrieves load averages from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run node-load.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-load", &orchestrator.Op{
+ Operation: "node.load.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-memory.go b/examples/sdk/orchestrator/operations/node-memory.go
new file mode 100644
index 00000000..b10cf011
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-memory.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.memory.get operation, which
+// retrieves memory statistics from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run node-memory.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-memory", &orchestrator.Op{
+ Operation: "node.memory.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-status.go b/examples/sdk/orchestrator/operations/node-status.go
new file mode 100644
index 00000000..731560a1
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-status.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.status.get operation, which
+// retrieves the node status from the target.
+//
+// Run with: OSAPI_TOKEN="" go run node-status.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-status", &orchestrator.Op{
+ Operation: "node.status.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/examples/sdk/orchestrator/operations/node-uptime.go b/examples/sdk/orchestrator/operations/node-uptime.go
new file mode 100644
index 00000000..4ff4331e
--- /dev/null
+++ b/examples/sdk/orchestrator/operations/node-uptime.go
@@ -0,0 +1,80 @@
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+//go:build ignore
+
+// Package main demonstrates the node.uptime.get operation, which
+// retrieves the system uptime from the target node.
+//
+// Run with: OSAPI_TOKEN="" go run node-uptime.go
+package main
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+func main() {
+ url := os.Getenv("OSAPI_URL")
+ if url == "" {
+ url = "http://localhost:8080"
+ }
+
+ token := os.Getenv("OSAPI_TOKEN")
+ if token == "" {
+ log.Fatal("OSAPI_TOKEN is required")
+ }
+
+ c := client.New(url, token)
+
+ hooks := orchestrator.Hooks{
+ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) {
+ fmt.Printf("[%s] %s changed=%v\n",
+ result.Status, result.Name, result.Changed)
+ },
+ }
+
+ plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks))
+
+ plan.Task("get-uptime", &orchestrator.Op{
+ Operation: "node.uptime.get",
+ Target: "_any",
+ })
+
+ report, err := plan.Run(context.Background())
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ for _, r := range report.Tasks {
+ if len(r.Data) > 0 {
+ b, _ := json.MarshalIndent(r.Data, "", " ")
+ fmt.Printf("data: %s\n", b)
+ }
+ }
+
+ fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration)
+}
diff --git a/go.mod b/go.mod
index 0f708c78..03203d5a 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,6 @@ go 1.25.0
require (
github.com/caarlos0/go-version v0.2.2
- github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/ggwhite/go-masker/v2 v2.2.0
github.com/go-playground/validator/v10 v10.30.1
@@ -108,7 +107,6 @@ require (
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
@@ -192,15 +190,12 @@ require (
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
- github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/minio/highwayhash v1.0.4-0.20251030100505-070ab1a87a76 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
- github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
diff --git a/go.sum b/go.sum
index 6769a950..bb7f1644 100644
--- a/go.sum
+++ b/go.sum
@@ -184,8 +184,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charithe/durationcheck v0.0.11 h1:g1/EX1eIiKS57NTWsYtHDZ/APfeXKhye1DidBcABctk=
github.com/charithe/durationcheck v0.0.11/go.mod h1:x5iZaixRNl8ctbM+3B2RrPG5t856TxRyVQEnbIEM2X4=
-github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
-github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
@@ -262,8 +260,6 @@ github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.
github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q=
github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
@@ -420,8 +416,6 @@ github.com/golangci/go-printf-func-name v0.1.1 h1:hIYTFJqAGp1iwoIfsNTpoq1xZAarog
github.com/golangci/go-printf-func-name v0.1.1/go.mod h1:Es64MpWEZbh0UBtTAICOZiB+miW53w/K9Or/4QogJss=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d h1:viFft9sS/dxoYY0aiOTsLKO2aZQAPT4nlQCsimGcSGE=
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d/go.mod h1:ivJ9QDg0XucIkmwhzCDsqcnxxlDStoTl89jDMIoNxKY=
-github.com/golangci/golangci-lint/v2 v2.11.1 h1:aGbjflzzKNIdOoq/NawrhFjYpkNY4WzPSeIp2zBbzG8=
-github.com/golangci/golangci-lint/v2 v2.11.1/go.mod h1:wexdFBIQNhHNhDe1oqzlGFE5dYUqlfccWJKWjoWF1GI=
github.com/golangci/golangci-lint/v2 v2.11.2 h1:4Icd3mEqthcFcFww8L67OBtfKB/obXxko8aFUMqP/5w=
github.com/golangci/golangci-lint/v2 v2.11.2/go.mod h1:wexdFBIQNhHNhDe1oqzlGFE5dYUqlfccWJKWjoWF1GI=
github.com/golangci/golines v0.15.0 h1:Qnph25g8Y1c5fdo1X7GaRDGgnMHgnxh4Gk4VfPTtRx0=
@@ -666,8 +660,6 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
@@ -698,10 +690,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/moricho/tparallel v0.3.2 h1:odr8aZVFA3NZrNybggMkYO3rgPRcqjeQUlBBFVxKHTI=
github.com/moricho/tparallel v0.3.2/go.mod h1:OQ+K3b4Ln3l2TZveGCywybl68glfLEwFGqvnjok8b+U=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
-github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
-github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -1282,7 +1270,6 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/internal/api/gen/api.yaml b/internal/api/gen/api.yaml
index c8185209..17586f09 100644
--- a/internal/api/gen/api.yaml
+++ b/internal/api/gen/api.yaml
@@ -850,41 +850,6 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
- /job/status:
- servers: []
- get:
- summary: Get queue statistics
- description: Retrieve statistics about the job queue.
- tags:
- - Job_Management_API_job_operations
- security:
- - BearerAuth:
- - job:read
- responses:
- '200':
- description: Queue statistics.
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/QueueStatsResponse'
- '401':
- description: Unauthorized - API key required
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- '403':
- description: Forbidden - Insufficient permissions
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
- '500':
- description: Error retrieving queue statistics.
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ErrorResponse'
/job/{id}:
servers: []
get:
@@ -2633,21 +2598,6 @@ components:
error:
type: string
description: Error details if applicable.
- QueueStatsResponse:
- type: object
- properties:
- total_jobs:
- type: integer
- description: Total number of jobs in the queue.
- example: 42
- status_counts:
- type: object
- additionalProperties:
- type: integer
- description: Count of jobs by status.
- dlq_count:
- type: integer
- description: Number of jobs in the dead letter queue.
DiskResponse:
type: object
description: Local disk usage information.
diff --git a/internal/api/job/gen/api.yaml b/internal/api/job/gen/api.yaml
index d4e6ab10..01a23dd9 100644
--- a/internal/api/job/gen/api.yaml
+++ b/internal/api/job/gen/api.yaml
@@ -151,41 +151,6 @@ paths:
schema:
$ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse'
- /job/status:
- get:
- summary: Get queue statistics
- description: Retrieve statistics about the job queue.
- tags:
- - job_operations
- security:
- - BearerAuth:
- - job:read
- responses:
- '200':
- description: Queue statistics.
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/QueueStatsResponse'
- '401':
- description: Unauthorized - API key required
- content:
- application/json:
- schema:
- $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse'
- '403':
- description: Forbidden - Insufficient permissions
- content:
- application/json:
- schema:
- $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse'
- '500':
- description: Error retrieving queue statistics.
- content:
- application/json:
- schema:
- $ref: '../../common/gen/api.yaml#/components/schemas/ErrorResponse'
-
/job/{id}:
get:
summary: Get job detail
@@ -528,22 +493,6 @@ components:
type: string
description: Error details if applicable.
- QueueStatsResponse:
- type: object
- properties:
- total_jobs:
- type: integer
- description: Total number of jobs in the queue.
- example: 42
- status_counts:
- type: object
- additionalProperties:
- type: integer
- description: Count of jobs by status.
- dlq_count:
- type: integer
- description: Number of jobs in the dead letter queue.
-
securitySchemes:
BearerAuth:
type: http
diff --git a/internal/api/job/gen/job.gen.go b/internal/api/job/gen/job.gen.go
index 4c89eb37..63e0ed5d 100644
--- a/internal/api/job/gen/job.gen.go
+++ b/internal/api/job/gen/job.gen.go
@@ -128,18 +128,6 @@ type ListJobsResponse struct {
TotalItems *int `json:"total_items,omitempty"`
}
-// QueueStatsResponse defines model for QueueStatsResponse.
-type QueueStatsResponse struct {
- // DlqCount Number of jobs in the dead letter queue.
- DlqCount *int `json:"dlq_count,omitempty"`
-
- // StatusCounts Count of jobs by status.
- StatusCounts *map[string]int `json:"status_counts,omitempty"`
-
- // TotalJobs Total number of jobs in the queue.
- TotalJobs *int `json:"total_jobs,omitempty"`
-}
-
// RetryJobRequest defines model for RetryJobRequest.
type RetryJobRequest struct {
// TargetHostname Override target hostname for the retried job. Defaults to _any if not specified.
@@ -175,9 +163,6 @@ type ServerInterface interface {
// Create a new job
// (POST /job)
PostJob(ctx echo.Context) error
- // Get queue statistics
- // (GET /job/status)
- GetJobStatus(ctx echo.Context) error
// Delete a job
// (DELETE /job/{id})
DeleteJobByID(ctx echo.Context, id openapi_types.UUID) error
@@ -239,17 +224,6 @@ func (w *ServerInterfaceWrapper) PostJob(ctx echo.Context) error {
return err
}
-// GetJobStatus converts echo context to params.
-func (w *ServerInterfaceWrapper) GetJobStatus(ctx echo.Context) error {
- var err error
-
- ctx.Set(BearerAuthScopes, []string{"job:read"})
-
- // Invoke the callback with all the unmarshaled arguments
- err = w.Handler.GetJobStatus(ctx)
- return err
-}
-
// DeleteJobByID converts echo context to params.
func (w *ServerInterfaceWrapper) DeleteJobByID(ctx echo.Context) error {
var err error
@@ -334,7 +308,6 @@ func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL
router.GET(baseURL+"/job", wrapper.GetJob)
router.POST(baseURL+"/job", wrapper.PostJob)
- router.GET(baseURL+"/job/status", wrapper.GetJobStatus)
router.DELETE(baseURL+"/job/:id", wrapper.DeleteJobByID)
router.GET(baseURL+"/job/:id", wrapper.GetJobByID)
router.POST(baseURL+"/job/:id/retry", wrapper.RetryJobByID)
@@ -447,49 +420,6 @@ func (response PostJob500JSONResponse) VisitPostJobResponse(w http.ResponseWrite
return json.NewEncoder(w).Encode(response)
}
-type GetJobStatusRequestObject struct {
-}
-
-type GetJobStatusResponseObject interface {
- VisitGetJobStatusResponse(w http.ResponseWriter) error
-}
-
-type GetJobStatus200JSONResponse QueueStatsResponse
-
-func (response GetJobStatus200JSONResponse) VisitGetJobStatusResponse(w http.ResponseWriter) error {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(200)
-
- return json.NewEncoder(w).Encode(response)
-}
-
-type GetJobStatus401JSONResponse externalRef0.ErrorResponse
-
-func (response GetJobStatus401JSONResponse) VisitGetJobStatusResponse(w http.ResponseWriter) error {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(401)
-
- return json.NewEncoder(w).Encode(response)
-}
-
-type GetJobStatus403JSONResponse externalRef0.ErrorResponse
-
-func (response GetJobStatus403JSONResponse) VisitGetJobStatusResponse(w http.ResponseWriter) error {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(403)
-
- return json.NewEncoder(w).Encode(response)
-}
-
-type GetJobStatus500JSONResponse externalRef0.ErrorResponse
-
-func (response GetJobStatus500JSONResponse) VisitGetJobStatusResponse(w http.ResponseWriter) error {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(500)
-
- return json.NewEncoder(w).Encode(response)
-}
-
type DeleteJobByIDRequestObject struct {
Id openapi_types.UUID `json:"id"`
}
@@ -684,9 +614,6 @@ type StrictServerInterface interface {
// Create a new job
// (POST /job)
PostJob(ctx context.Context, request PostJobRequestObject) (PostJobResponseObject, error)
- // Get queue statistics
- // (GET /job/status)
- GetJobStatus(ctx context.Context, request GetJobStatusRequestObject) (GetJobStatusResponseObject, error)
// Delete a job
// (DELETE /job/{id})
DeleteJobByID(ctx context.Context, request DeleteJobByIDRequestObject) (DeleteJobByIDResponseObject, error)
@@ -764,29 +691,6 @@ func (sh *strictHandler) PostJob(ctx echo.Context) error {
return nil
}
-// GetJobStatus operation middleware
-func (sh *strictHandler) GetJobStatus(ctx echo.Context) error {
- var request GetJobStatusRequestObject
-
- handler := func(ctx echo.Context, request interface{}) (interface{}, error) {
- return sh.ssi.GetJobStatus(ctx.Request().Context(), request.(GetJobStatusRequestObject))
- }
- for _, middleware := range sh.middlewares {
- handler = middleware(handler, "GetJobStatus")
- }
-
- response, err := handler(ctx, request)
-
- if err != nil {
- return err
- } else if validResponse, ok := response.(GetJobStatusResponseObject); ok {
- return validResponse.VisitGetJobStatusResponse(ctx.Response())
- } else if response != nil {
- return fmt.Errorf("unexpected response type: %T", response)
- }
- return nil
-}
-
// DeleteJobByID operation middleware
func (sh *strictHandler) DeleteJobByID(ctx echo.Context, id openapi_types.UUID) error {
var request DeleteJobByIDRequestObject
diff --git a/internal/api/job/job_status.go b/internal/api/job/job_status.go
deleted file mode 100644
index bff4f7bd..00000000
--- a/internal/api/job/job_status.go
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (c) 2026 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package job
-
-import (
- "context"
-
- "github.com/retr0h/osapi/internal/api/job/gen"
-)
-
-// GetJobStatus retrieves queue statistics.
-func (j *Job) GetJobStatus(
- ctx context.Context,
- _ gen.GetJobStatusRequestObject,
-) (gen.GetJobStatusResponseObject, error) {
- stats, err := j.JobClient.GetQueueSummary(ctx)
- if err != nil {
- errMsg := err.Error()
- return gen.GetJobStatus500JSONResponse{
- Error: &errMsg,
- }, nil
- }
-
- return gen.GetJobStatus200JSONResponse{
- TotalJobs: &stats.TotalJobs,
- StatusCounts: &stats.StatusCounts,
- DlqCount: &stats.DLQCount,
- }, nil
-}
diff --git a/internal/api/job/job_status_public_test.go b/internal/api/job/job_status_public_test.go
deleted file mode 100644
index 6af77f6a..00000000
--- a/internal/api/job/job_status_public_test.go
+++ /dev/null
@@ -1,289 +0,0 @@
-// Copyright (c) 2026 John Dewey
-
-// Permission is hereby granted, free of charge, to any person obtaining a copy
-// of this software and associated documentation files (the "Software"), to
-// deal in the Software without restriction, including without limitation the
-// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
-// sell copies of the Software, and to permit persons to whom the Software is
-// furnished to do so, subject to the following conditions:
-
-// The above copyright notice and this permission notice shall be included in
-// all copies or substantial portions of the Software.
-
-// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
-// DEALINGS IN THE SOFTWARE.
-
-package job_test
-
-import (
- "context"
- "fmt"
- "log/slog"
- "net/http"
- "net/http/httptest"
- "os"
- "testing"
-
- "github.com/golang/mock/gomock"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/suite"
-
- "github.com/retr0h/osapi/internal/api"
- apijob "github.com/retr0h/osapi/internal/api/job"
- "github.com/retr0h/osapi/internal/api/job/gen"
- "github.com/retr0h/osapi/internal/authtoken"
- "github.com/retr0h/osapi/internal/config"
- jobtypes "github.com/retr0h/osapi/internal/job"
- jobmocks "github.com/retr0h/osapi/internal/job/mocks"
-)
-
-type JobStatusPublicTestSuite struct {
- suite.Suite
-
- mockCtrl *gomock.Controller
- mockJobClient *jobmocks.MockJobClient
- handler *apijob.Job
- ctx context.Context
- appConfig config.Config
- logger *slog.Logger
-}
-
-func (s *JobStatusPublicTestSuite) SetupTest() {
- s.mockCtrl = gomock.NewController(s.T())
- s.mockJobClient = jobmocks.NewMockJobClient(s.mockCtrl)
- s.handler = apijob.New(slog.Default(), s.mockJobClient)
- s.ctx = context.Background()
- s.appConfig = config.Config{}
- s.logger = slog.New(slog.NewTextHandler(os.Stdout, nil))
-}
-
-func (s *JobStatusPublicTestSuite) TearDownTest() {
- s.mockCtrl.Finish()
-}
-
-func (s *JobStatusPublicTestSuite) TestGetJobStatus() {
- tests := []struct {
- name string
- mockStats *jobtypes.QueueStats
- mockError error
- validateFunc func(resp gen.GetJobStatusResponseObject)
- }{
- {
- name: "success",
- mockStats: &jobtypes.QueueStats{
- TotalJobs: 42,
- StatusCounts: map[string]int{
- "completed": 30,
- "failed": 5,
- },
- DLQCount: 2,
- },
- validateFunc: func(resp gen.GetJobStatusResponseObject) {
- r, ok := resp.(gen.GetJobStatus200JSONResponse)
- s.True(ok)
- s.Equal(42, *r.TotalJobs)
- s.Equal(2, *r.DlqCount)
- },
- },
- {
- name: "job client error",
- mockError: assert.AnError,
- validateFunc: func(resp gen.GetJobStatusResponseObject) {
- _, ok := resp.(gen.GetJobStatus500JSONResponse)
- s.True(ok)
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- s.mockJobClient.EXPECT().
- GetQueueSummary(gomock.Any()).
- Return(tt.mockStats, tt.mockError)
-
- resp, err := s.handler.GetJobStatus(s.ctx, gen.GetJobStatusRequestObject{})
- s.NoError(err)
- tt.validateFunc(resp)
- })
- }
-}
-
-func (s *JobStatusPublicTestSuite) TestGetJobStatusHTTP() {
- tests := []struct {
- name string
- setupJobMock func() *jobmocks.MockJobClient
- wantCode int
- wantContains []string
- }{
- {
- name: "when valid request returns queue stats",
- setupJobMock: func() *jobmocks.MockJobClient {
- mock := jobmocks.NewMockJobClient(s.mockCtrl)
- mock.EXPECT().
- GetQueueSummary(gomock.Any()).
- Return(&jobtypes.QueueStats{
- TotalJobs: 42,
- StatusCounts: map[string]int{
- "completed": 30,
- "failed": 5,
- },
- DLQCount: 2,
- }, nil)
- return mock
- },
- wantCode: http.StatusOK,
- wantContains: []string{`"total_jobs":42`, `"dlq_count":2`},
- },
- {
- name: "when job client errors returns 500",
- setupJobMock: func() *jobmocks.MockJobClient {
- mock := jobmocks.NewMockJobClient(s.mockCtrl)
- mock.EXPECT().
- GetQueueSummary(gomock.Any()).
- Return(nil, assert.AnError)
- return mock
- },
- wantCode: http.StatusInternalServerError,
- wantContains: []string{`"error"`},
- },
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- jobMock := tc.setupJobMock()
-
- jobHandler := apijob.New(s.logger, jobMock)
- strictHandler := gen.NewStrictHandler(jobHandler, nil)
-
- a := api.New(s.appConfig, s.logger)
- gen.RegisterHandlers(a.Echo, strictHandler)
-
- req := httptest.NewRequest(http.MethodGet, "/job/status", nil)
- rec := httptest.NewRecorder()
-
- a.Echo.ServeHTTP(rec, req)
-
- s.Equal(tc.wantCode, rec.Code)
- for _, str := range tc.wantContains {
- s.Contains(rec.Body.String(), str)
- }
- })
- }
-}
-
-const rbacJobStatusTestSigningKey = "test-signing-key-for-rbac-integration"
-
-func (s *JobStatusPublicTestSuite) TestGetJobStatusRBACHTTP() {
- tokenManager := authtoken.New(s.logger)
-
- tests := []struct {
- name string
- setupAuth func(req *http.Request)
- setupJobMock func() *jobmocks.MockJobClient
- wantCode int
- wantContains []string
- }{
- {
- name: "when no token returns 401",
- setupAuth: func(_ *http.Request) {
- // No auth header set
- },
- setupJobMock: func() *jobmocks.MockJobClient {
- return jobmocks.NewMockJobClient(s.mockCtrl)
- },
- wantCode: http.StatusUnauthorized,
- wantContains: []string{"Bearer token required"},
- },
- {
- name: "when insufficient permissions returns 403",
- setupAuth: func(req *http.Request) {
- token, err := tokenManager.Generate(
- rbacJobStatusTestSigningKey,
- []string{"read"},
- "test-user",
- []string{"network:read"},
- )
- s.Require().NoError(err)
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
- },
- setupJobMock: func() *jobmocks.MockJobClient {
- return jobmocks.NewMockJobClient(s.mockCtrl)
- },
- wantCode: http.StatusForbidden,
- wantContains: []string{"Insufficient permissions"},
- },
- {
- name: "when valid token with job:read returns 200",
- setupAuth: func(req *http.Request) {
- token, err := tokenManager.Generate(
- rbacJobStatusTestSigningKey,
- []string{"admin"},
- "test-user",
- nil,
- )
- s.Require().NoError(err)
- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
- },
- setupJobMock: func() *jobmocks.MockJobClient {
- mock := jobmocks.NewMockJobClient(s.mockCtrl)
- mock.EXPECT().
- GetQueueSummary(gomock.Any()).
- Return(&jobtypes.QueueStats{
- TotalJobs: 42,
- StatusCounts: map[string]int{
- "completed": 30,
- "failed": 5,
- },
- DLQCount: 2,
- }, nil)
- return mock
- },
- wantCode: http.StatusOK,
- wantContains: []string{`"total_jobs":42`},
- },
- }
-
- for _, tc := range tests {
- s.Run(tc.name, func() {
- jobMock := tc.setupJobMock()
-
- appConfig := config.Config{
- API: config.API{
- Server: config.Server{
- Security: config.ServerSecurity{
- SigningKey: rbacJobStatusTestSigningKey,
- },
- },
- },
- }
-
- server := api.New(appConfig, s.logger)
- handlers := server.GetJobHandler(jobMock)
- server.RegisterHandlers(handlers)
-
- req := httptest.NewRequest(
- http.MethodGet,
- "/job/status",
- nil,
- )
- tc.setupAuth(req)
- rec := httptest.NewRecorder()
-
- server.Echo.ServeHTTP(rec, req)
-
- s.Equal(tc.wantCode, rec.Code)
- for _, str := range tc.wantContains {
- s.Contains(rec.Body.String(), str)
- }
- })
- }
-}
-
-func TestJobStatusPublicTestSuite(t *testing.T) {
- suite.Run(t, new(JobStatusPublicTestSuite))
-}
diff --git a/internal/audit/export/export_public_test.go b/internal/audit/export/export_public_test.go
index a91f6f1a..e57acee1 100644
--- a/internal/audit/export/export_public_test.go
+++ b/internal/audit/export/export_public_test.go
@@ -27,7 +27,7 @@ import (
"testing"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/stretchr/testify/suite"
"github.com/retr0h/osapi/internal/audit/export"
@@ -47,8 +47,8 @@ func (suite *ExportPublicTestSuite) SetupTest() {
func (suite *ExportPublicTestSuite) newEntry(
user string,
-) osapi.AuditEntry {
- return osapi.AuditEntry{
+) client.AuditEntry {
+ return client.AuditEntry{
ID: "550e8400-e29b-41d4-a716-446655440000",
Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC),
User: user,
@@ -71,7 +71,7 @@ func (suite *ExportPublicTestSuite) TestRun() {
}{
{
name: "when no entries returns zero counts",
- fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) {
+ fetcher: func(_ context.Context, _, _ int) ([]client.AuditEntry, int, error) {
return nil, 0, nil
},
exporter: &mockExporter{},
@@ -86,8 +86,8 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when single page exports all entries",
- fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) {
- return []osapi.AuditEntry{
+ fetcher: func(_ context.Context, _, _ int) ([]client.AuditEntry, int, error) {
+ return []client.AuditEntry{
suite.newEntry("alice@example.com"),
suite.newEntry("bob@example.com"),
}, 2, nil
@@ -105,7 +105,7 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when multi-page paginates correctly",
- fetcher: newPagedFetcher([][]osapi.AuditEntry{
+ fetcher: newPagedFetcher([][]client.AuditEntry{
{suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com")},
{suite.newEntry("charlie@example.com")},
}, 3),
@@ -120,11 +120,11 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when fetcher errors returns partial result",
- fetcher: func(_ context.Context, _, offset int) ([]osapi.AuditEntry, int, error) {
+ fetcher: func(_ context.Context, _, offset int) ([]client.AuditEntry, int, error) {
if offset > 0 {
return nil, 0, fmt.Errorf("connection lost")
}
- return []osapi.AuditEntry{suite.newEntry("alice@example.com")}, 3, nil
+ return []client.AuditEntry{suite.newEntry("alice@example.com")}, 3, nil
},
exporter: &mockExporter{},
batchSize: 1,
@@ -138,8 +138,8 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when write errors returns partial result",
- fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) {
- return []osapi.AuditEntry{suite.newEntry("alice@example.com")}, 1, nil
+ fetcher: func(_ context.Context, _, _ int) ([]client.AuditEntry, int, error) {
+ return []client.AuditEntry{suite.newEntry("alice@example.com")}, 1, nil
},
exporter: &mockExporter{writeErr: fmt.Errorf("disk full")},
batchSize: 100,
@@ -151,7 +151,7 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when open errors returns nil result",
- fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) {
+ fetcher: func(_ context.Context, _, _ int) ([]client.AuditEntry, int, error) {
return nil, 0, nil
},
exporter: &mockExporter{openErr: fmt.Errorf("permission denied")},
@@ -164,7 +164,7 @@ func (suite *ExportPublicTestSuite) TestRun() {
},
{
name: "when close errors logs warning but returns result",
- fetcher: func(_ context.Context, _, _ int) ([]osapi.AuditEntry, int, error) {
+ fetcher: func(_ context.Context, _, _ int) ([]client.AuditEntry, int, error) {
return nil, 0, nil
},
exporter: &mockExporter{closeErr: fmt.Errorf("close failed")},
@@ -201,7 +201,7 @@ func (suite *ExportPublicTestSuite) TestRunProgress() {
}{
{
name: "when multi-page calls progress after each batch",
- fetcher: newPagedFetcher([][]osapi.AuditEntry{
+ fetcher: newPagedFetcher([][]client.AuditEntry{
{suite.newEntry("alice@example.com"), suite.newEntry("bob@example.com")},
{suite.newEntry("charlie@example.com")},
}, 3),
@@ -245,7 +245,7 @@ func TestExportPublicTestSuite(t *testing.T) {
type mockExporter struct {
opened bool
closed bool
- entries []osapi.AuditEntry
+ entries []client.AuditEntry
openErr error
writeErr error
closeErr error
@@ -263,7 +263,7 @@ func (m *mockExporter) Open(
func (m *mockExporter) Write(
_ context.Context,
- entry osapi.AuditEntry,
+ entry client.AuditEntry,
) error {
if m.writeErr != nil {
return m.writeErr
@@ -286,14 +286,14 @@ type progressCall struct {
// newPagedFetcher creates a fetcher that returns pages of entries based on offset.
func newPagedFetcher(
- pages [][]osapi.AuditEntry,
+ pages [][]client.AuditEntry,
total int,
) export.Fetcher {
return func(
_ context.Context,
limit int,
offset int,
- ) ([]osapi.AuditEntry, int, error) {
+ ) ([]client.AuditEntry, int, error) {
_ = limit
pageIdx := 0
remaining := offset
diff --git a/internal/audit/export/file.go b/internal/audit/export/file.go
index 491ca5bc..2725d4db 100644
--- a/internal/audit/export/file.go
+++ b/internal/audit/export/file.go
@@ -28,7 +28,7 @@ import (
"io"
"os"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
// marshalJSON is a package-level variable for testing the marshal error path.
@@ -99,7 +99,7 @@ func (e *FileExporter) Open(
// Write marshals an audit entry to JSON and writes it as a single line.
func (e *FileExporter) Write(
_ context.Context,
- entry osapi.AuditEntry,
+ entry client.AuditEntry,
) error {
if e.writer == nil {
return fmt.Errorf("exporter not opened")
diff --git a/internal/audit/export/file_public_test.go b/internal/audit/export/file_public_test.go
index 5bd1b423..24515978 100644
--- a/internal/audit/export/file_public_test.go
+++ b/internal/audit/export/file_public_test.go
@@ -31,7 +31,7 @@ import (
"testing"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/stretchr/testify/suite"
"github.com/retr0h/osapi/internal/audit/export"
@@ -51,8 +51,8 @@ func (suite *FileExporterPublicTestSuite) SetupTest() {
func (suite *FileExporterPublicTestSuite) newEntry(
user string,
-) osapi.AuditEntry {
- return osapi.AuditEntry{
+) client.AuditEntry {
+ return client.AuditEntry{
ID: "550e8400-e29b-41d4-a716-446655440000",
Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC),
User: user,
@@ -68,17 +68,17 @@ func (suite *FileExporterPublicTestSuite) newEntry(
func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() {
tests := []struct {
name string
- entries []osapi.AuditEntry
+ entries []client.AuditEntry
validateFunc func(path string)
}{
{
name: "when single entry writes valid JSONL",
- entries: []osapi.AuditEntry{suite.newEntry("alice@example.com")},
+ entries: []client.AuditEntry{suite.newEntry("alice@example.com")},
validateFunc: func(path string) {
lines := suite.readLines(path)
suite.Len(lines, 1)
- var entry osapi.AuditEntry
+ var entry client.AuditEntry
err := json.Unmarshal([]byte(lines[0]), &entry)
suite.NoError(err)
suite.Equal("alice@example.com", entry.User)
@@ -86,7 +86,7 @@ func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() {
},
{
name: "when multiple entries writes valid JSONL",
- entries: []osapi.AuditEntry{
+ entries: []client.AuditEntry{
suite.newEntry("alice@example.com"),
suite.newEntry("bob@example.com"),
suite.newEntry("charlie@example.com"),
@@ -96,7 +96,7 @@ func (suite *FileExporterPublicTestSuite) TestOpenWriteClose() {
suite.Len(lines, 3)
for i, user := range []string{"alice@example.com", "bob@example.com", "charlie@example.com"} {
- var entry osapi.AuditEntry
+ var entry client.AuditEntry
err := json.Unmarshal([]byte(lines[i]), &entry)
suite.NoError(err)
suite.Equal(user, entry.User)
diff --git a/internal/audit/export/file_test.go b/internal/audit/export/file_test.go
index 2cff9e24..ee0d4ebd 100644
--- a/internal/audit/export/file_test.go
+++ b/internal/audit/export/file_test.go
@@ -28,7 +28,7 @@ import (
"testing"
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/stretchr/testify/suite"
)
@@ -37,7 +37,7 @@ type FileTestSuite struct {
}
func (s *FileTestSuite) TestWriteNewlineError() {
- entry := osapi.AuditEntry{
+ entry := client.AuditEntry{
ID: "550e8400-e29b-41d4-a716-446655440000",
Timestamp: time.Date(2026, 2, 21, 10, 30, 0, 0, time.UTC),
User: "user@example.com",
diff --git a/internal/audit/export/types.go b/internal/audit/export/types.go
index e6fc85c4..a87d523b 100644
--- a/internal/audit/export/types.go
+++ b/internal/audit/export/types.go
@@ -23,13 +23,13 @@ package export
import (
"context"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
// Exporter writes audit entries to a backend.
type Exporter interface {
Open(ctx context.Context) error
- Write(ctx context.Context, entry osapi.AuditEntry) error
+ Write(ctx context.Context, entry client.AuditEntry) error
Close(ctx context.Context) error
}
@@ -39,7 +39,7 @@ type Fetcher func(
ctx context.Context,
limit int,
offset int,
-) ([]osapi.AuditEntry, int, error)
+) ([]client.AuditEntry, int, error)
// Result holds export outcome.
type Result struct {
diff --git a/internal/cli/ui.go b/internal/cli/ui.go
index ef2fafa9..67904daf 100644
--- a/internal/cli/ui.go
+++ b/internal/cli/ui.go
@@ -31,7 +31,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
// Theme colors for terminal UI rendering.
@@ -492,7 +492,7 @@ func HandleError(
err error,
logger *slog.Logger,
) {
- var apiErr *osapi.APIError
+ var apiErr *client.APIError
if errors.As(err, &apiErr) {
logger.Error(
"api error",
@@ -509,7 +509,7 @@ func HandleError(
// DisplayJobDetail displays detailed job information from domain types.
// Used by both job get and job run commands.
func DisplayJobDetail(
- resp *osapi.JobDetail,
+ resp *client.JobDetail,
) {
// Display job metadata
fmt.Println()
diff --git a/internal/cli/ui_public_test.go b/internal/cli/ui_public_test.go
index c9020ee4..b2b881c7 100644
--- a/internal/cli/ui_public_test.go
+++ b/internal/cli/ui_public_test.go
@@ -31,7 +31,7 @@ import (
"time"
"github.com/google/uuid"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
@@ -573,29 +573,29 @@ func (suite *UIPublicTestSuite) TestHandleError() {
}{
{
name: "when auth error logs api error with status code",
- err: &osapi.AuthError{
- APIError: osapi.APIError{StatusCode: 403, Message: "insufficient permissions"},
+ err: &client.AuthError{
+ APIError: client.APIError{StatusCode: 403, Message: "insufficient permissions"},
},
wantInLog: "insufficient permissions",
},
{
name: "when not found error logs api error with status code",
- err: &osapi.NotFoundError{
- APIError: osapi.APIError{StatusCode: 404, Message: "job not found"},
+ err: &client.NotFoundError{
+ APIError: client.APIError{StatusCode: 404, Message: "job not found"},
},
wantInLog: "job not found",
},
{
name: "when validation error logs api error with status code",
- err: &osapi.ValidationError{
- APIError: osapi.APIError{StatusCode: 400, Message: "invalid input"},
+ err: &client.ValidationError{
+ APIError: client.APIError{StatusCode: 400, Message: "invalid input"},
},
wantInLog: "invalid input",
},
{
name: "when server error logs api error with status code",
- err: &osapi.ServerError{
- APIError: osapi.APIError{StatusCode: 500, Message: "internal server error"},
+ err: &client.ServerError{
+ APIError: client.APIError{StatusCode: 500, Message: "internal server error"},
},
wantInLog: "internal server error",
},
@@ -863,17 +863,17 @@ func (suite *UIPublicTestSuite) TestFormatBytes() {
func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
tests := []struct {
name string
- resp *osapi.JobDetail
+ resp *client.JobDetail
}{
{
name: "when minimal response displays job info",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
Status: "completed",
},
},
{
name: "when full response displays all sections",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
ID: "550e8400-e29b-41d4-a716-446655440000",
Status: "completed",
Hostname: "web-01",
@@ -882,7 +882,7 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
Error: "timeout",
Operation: map[string]any{"type": "node.hostname"},
Result: map[string]any{"hostname": "web-01"},
- Timeline: []osapi.TimelineEvent{
+ Timeline: []client.TimelineEvent{
{
Event: "completed",
Timestamp: "2026-01-01T00:01:00Z",
@@ -890,13 +890,13 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
Message: "job completed",
},
},
- AgentStates: map[string]osapi.AgentState{
+ AgentStates: map[string]client.AgentState{
"web-01": {
Status: "completed",
Duration: "1.5s",
},
},
- Responses: map[string]osapi.AgentJobResponse{
+ Responses: map[string]client.AgentJobResponse{
"web-01": {
Status: "ok",
Data: map[string]string{"key": "val"},
@@ -906,9 +906,9 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
},
{
name: "when agent states with multiple agents shows summary",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
Status: "completed",
- AgentStates: map[string]osapi.AgentState{
+ AgentStates: map[string]client.AgentState{
"web-01": {Status: "completed", Duration: "1s"},
"web-02": {Status: "failed", Duration: "1s", Error: "error"},
"web-03": {Status: "started", Duration: "1s"},
@@ -917,9 +917,9 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
},
{
name: "when response has nil data shows no data placeholder",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
Status: "completed",
- Responses: map[string]osapi.AgentJobResponse{
+ Responses: map[string]client.AgentJobResponse{
"web-01": {
Status: "ok",
Data: nil,
@@ -929,9 +929,9 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
},
{
name: "when response has error shows error message",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
Status: "failed",
- Responses: map[string]osapi.AgentJobResponse{
+ Responses: map[string]client.AgentJobResponse{
"web-01": {
Status: "failed",
Error: "timeout",
@@ -941,9 +941,9 @@ func (suite *UIPublicTestSuite) TestDisplayJobDetail() {
},
{
name: "when timeline has error shows error message",
- resp: &osapi.JobDetail{
+ resp: &client.JobDetail{
Status: "failed",
- Timeline: []osapi.TimelineEvent{
+ Timeline: []client.TimelineEvent{
{
Event: "failed",
Timestamp: "2026-01-01T00:01:00Z",
diff --git a/pkg/sdk/osapi/agent.go b/pkg/sdk/client/agent.go
similarity index 98%
rename from pkg/sdk/osapi/agent.go
rename to pkg/sdk/client/agent.go
index 6968a1d3..3c306926 100644
--- a/pkg/sdk/osapi/agent.go
+++ b/pkg/sdk/client/agent.go
@@ -18,13 +18,13 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// MessageResponse represents a simple message response from the API.
diff --git a/pkg/sdk/osapi/agent_public_test.go b/pkg/sdk/client/agent_public_test.go
similarity index 81%
rename from pkg/sdk/osapi/agent_public_test.go
rename to pkg/sdk/client/agent_public_test.go
index 989cd57f..43cddb3c 100644
--- a/pkg/sdk/osapi/agent_public_test.go
+++ b/pkg/sdk/client/agent_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type AgentPublicTestSuite struct {
@@ -48,7 +48,7 @@ func (suite *AgentPublicTestSuite) TestList() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.AgentList], error)
+ validateFunc func(*client.Response[client.AgentList], error)
}{
{
name: "when requesting agents returns no error",
@@ -57,7 +57,7 @@ func (suite *AgentPublicTestSuite) TestList() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"agents":[],"total":0}`))
},
- validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) {
+ validateFunc: func(resp *client.Response[client.AgentList], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal(0, resp.Data.Total)
@@ -71,11 +71,11 @@ func (suite *AgentPublicTestSuite) TestList() {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) {
+ validateFunc: func(resp *client.Response[client.AgentList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusUnauthorized, target.StatusCode)
},
@@ -83,7 +83,7 @@ func (suite *AgentPublicTestSuite) TestList() {
{
name: "when client HTTP error returns wrapped error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) {
+ validateFunc: func(resp *client.Response[client.AgentList], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "list agents")
@@ -95,11 +95,11 @@ func (suite *AgentPublicTestSuite) TestList() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.AgentList], err error) {
+ validateFunc: func(resp *client.Response[client.AgentList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -116,10 +116,10 @@ func (suite *AgentPublicTestSuite) TestList() {
url = server.URL
}
- sut := osapi.New(
+ sut := client.New(
url,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Agent.List(suite.ctx)
@@ -134,7 +134,7 @@ func (suite *AgentPublicTestSuite) TestGet() {
handler http.HandlerFunc
serverURL string
hostname string
- validateFunc func(*osapi.Response[osapi.Agent], error)
+ validateFunc func(*client.Response[client.Agent], error)
}{
{
name: "when requesting agent details returns no error",
@@ -144,7 +144,7 @@ func (suite *AgentPublicTestSuite) TestGet() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"hostname":"server1","status":"Ready"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Agent], err error) {
+ validateFunc: func(resp *client.Response[client.Agent], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("server1", resp.Data.Hostname)
@@ -159,11 +159,11 @@ func (suite *AgentPublicTestSuite) TestGet() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"agent not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Agent], err error) {
+ validateFunc: func(resp *client.Response[client.Agent], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
suite.Equal("agent not found", target.Message)
@@ -173,7 +173,7 @@ func (suite *AgentPublicTestSuite) TestGet() {
name: "when client HTTP error returns wrapped error",
hostname: "unknown-host",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Agent], err error) {
+ validateFunc: func(resp *client.Response[client.Agent], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get agent")
@@ -186,11 +186,11 @@ func (suite *AgentPublicTestSuite) TestGet() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Agent], err error) {
+ validateFunc: func(resp *client.Response[client.Agent], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -207,10 +207,10 @@ func (suite *AgentPublicTestSuite) TestGet() {
url = server.URL
}
- sut := osapi.New(
+ sut := client.New(
url,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Agent.Get(suite.ctx, tc.hostname)
@@ -225,7 +225,7 @@ func (suite *AgentPublicTestSuite) TestDrain() {
handler http.HandlerFunc
serverURL string
hostname string
- validateFunc func(*osapi.Response[osapi.MessageResponse], error)
+ validateFunc func(*client.Response[client.MessageResponse], error)
}{
{
name: "when draining agent returns success",
@@ -235,7 +235,7 @@ func (suite *AgentPublicTestSuite) TestDrain() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"message":"drain initiated for agent server1"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("drain initiated for agent server1", resp.Data.Message)
@@ -249,11 +249,11 @@ func (suite *AgentPublicTestSuite) TestDrain() {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"agent already draining"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ConflictError
+ var target *client.ConflictError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusConflict, target.StatusCode)
suite.Equal("agent already draining", target.Message)
@@ -267,11 +267,11 @@ func (suite *AgentPublicTestSuite) TestDrain() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"agent not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -280,7 +280,7 @@ func (suite *AgentPublicTestSuite) TestDrain() {
name: "when client HTTP error returns wrapped error",
hostname: "test-host",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "drain agent")
@@ -293,11 +293,11 @@ func (suite *AgentPublicTestSuite) TestDrain() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -314,10 +314,10 @@ func (suite *AgentPublicTestSuite) TestDrain() {
url = server.URL
}
- sut := osapi.New(
+ sut := client.New(
url,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Agent.Drain(suite.ctx, tc.hostname)
@@ -332,7 +332,7 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
handler http.HandlerFunc
serverURL string
hostname string
- validateFunc func(*osapi.Response[osapi.MessageResponse], error)
+ validateFunc func(*client.Response[client.MessageResponse], error)
}{
{
name: "when undraining agent returns success",
@@ -342,7 +342,7 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"message":"undrain initiated for agent server1"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("undrain initiated for agent server1", resp.Data.Message)
@@ -356,11 +356,11 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"agent not in draining state"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ConflictError
+ var target *client.ConflictError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusConflict, target.StatusCode)
suite.Equal("agent not in draining state", target.Message)
@@ -374,11 +374,11 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"agent not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -387,7 +387,7 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
name: "when client HTTP error returns wrapped error",
hostname: "test-host",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "undrain agent")
@@ -400,11 +400,11 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.MessageResponse], err error) {
+ validateFunc: func(resp *client.Response[client.MessageResponse], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -421,10 +421,10 @@ func (suite *AgentPublicTestSuite) TestUndrain() {
url = server.URL
}
- sut := osapi.New(
+ sut := client.New(
url,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Agent.Undrain(suite.ctx, tc.hostname)
diff --git a/pkg/sdk/osapi/agent_types.go b/pkg/sdk/client/agent_types.go
similarity index 98%
rename from pkg/sdk/osapi/agent_types.go
rename to pkg/sdk/client/agent_types.go
index d7f9b052..e0ee686c 100644
--- a/pkg/sdk/osapi/agent_types.go
+++ b/pkg/sdk/client/agent_types.go
@@ -18,12 +18,12 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// Agent represents a registered OSAPI agent.
diff --git a/pkg/sdk/osapi/agent_types_test.go b/pkg/sdk/client/agent_types_test.go
similarity index 99%
rename from pkg/sdk/osapi/agent_types_test.go
rename to pkg/sdk/client/agent_types_test.go
index e638c11f..d63059bb 100644
--- a/pkg/sdk/osapi/agent_types_test.go
+++ b/pkg/sdk/client/agent_types_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
@@ -26,7 +26,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type AgentTypesTestSuite struct {
diff --git a/pkg/sdk/osapi/audit.go b/pkg/sdk/client/audit.go
similarity index 98%
rename from pkg/sdk/osapi/audit.go
rename to pkg/sdk/client/audit.go
index b06e3e2d..d673dd51 100644
--- a/pkg/sdk/osapi/audit.go
+++ b/pkg/sdk/client/audit.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
@@ -26,7 +26,7 @@ import (
"github.com/google/uuid"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// AuditService provides audit log operations.
diff --git a/pkg/sdk/osapi/audit_public_test.go b/pkg/sdk/client/audit_public_test.go
similarity index 84%
rename from pkg/sdk/osapi/audit_public_test.go
rename to pkg/sdk/client/audit_public_test.go
index 1318d9ef..664cf3ef 100644
--- a/pkg/sdk/osapi/audit_public_test.go
+++ b/pkg/sdk/client/audit_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type AuditPublicTestSuite struct {
@@ -50,7 +50,7 @@ func (suite *AuditPublicTestSuite) TestList() {
serverURL string
limit int
offset int
- validateFunc func(*osapi.Response[osapi.AuditList], error)
+ validateFunc func(*client.Response[client.AuditList], error)
}{
{
name: "when listing audit entries returns audit list",
@@ -61,7 +61,7 @@ func (suite *AuditPublicTestSuite) TestList() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[],"total_items":0}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal(0, resp.Data.TotalItems)
@@ -77,11 +77,11 @@ func (suite *AuditPublicTestSuite) TestList() {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusUnauthorized, target.StatusCode)
},
@@ -91,7 +91,7 @@ func (suite *AuditPublicTestSuite) TestList() {
limit: 20,
offset: 0,
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "list audit logs:")
@@ -105,11 +105,11 @@ func (suite *AuditPublicTestSuite) TestList() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -126,10 +126,10 @@ func (suite *AuditPublicTestSuite) TestList() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Audit.List(suite.ctx, tc.limit, tc.offset)
@@ -144,7 +144,7 @@ func (suite *AuditPublicTestSuite) TestGet() {
handler http.HandlerFunc
serverURL string
id string
- validateFunc func(*osapi.Response[osapi.AuditEntry], error)
+ validateFunc func(*client.Response[client.AuditEntry], error)
}{
{
name: "when valid UUID returns audit entry",
@@ -158,7 +158,7 @@ func (suite *AuditPublicTestSuite) TestGet() {
),
)
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) {
+ validateFunc: func(resp *client.Response[client.AuditEntry], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.ID)
@@ -179,7 +179,7 @@ func (suite *AuditPublicTestSuite) TestGet() {
),
)
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) {
+ validateFunc: func(resp *client.Response[client.AuditEntry], err error) {
suite.Error(err)
suite.Nil(resp)
},
@@ -192,11 +192,11 @@ func (suite *AuditPublicTestSuite) TestGet() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"audit entry not found"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) {
+ validateFunc: func(resp *client.Response[client.AuditEntry], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -205,7 +205,7 @@ func (suite *AuditPublicTestSuite) TestGet() {
name: "when client HTTP request fails returns error",
id: "550e8400-e29b-41d4-a716-446655440000",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) {
+ validateFunc: func(resp *client.Response[client.AuditEntry], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get audit log")
@@ -218,11 +218,11 @@ func (suite *AuditPublicTestSuite) TestGet() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditEntry], err error) {
+ validateFunc: func(resp *client.Response[client.AuditEntry], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -239,10 +239,10 @@ func (suite *AuditPublicTestSuite) TestGet() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Audit.Get(suite.ctx, tc.id)
@@ -256,7 +256,7 @@ func (suite *AuditPublicTestSuite) TestExport() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.AuditList], error)
+ validateFunc func(*client.Response[client.AuditList], error)
}{
{
name: "when exporting audit entries returns audit list",
@@ -265,7 +265,7 @@ func (suite *AuditPublicTestSuite) TestExport() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[],"total_items":0}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal(0, resp.Data.TotalItems)
@@ -279,11 +279,11 @@ func (suite *AuditPublicTestSuite) TestExport() {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusUnauthorized, target.StatusCode)
},
@@ -291,7 +291,7 @@ func (suite *AuditPublicTestSuite) TestExport() {
{
name: "when client HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "export audit logs:")
@@ -303,11 +303,11 @@ func (suite *AuditPublicTestSuite) TestExport() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.AuditList], err error) {
+ validateFunc: func(resp *client.Response[client.AuditList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -324,10 +324,10 @@ func (suite *AuditPublicTestSuite) TestExport() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Audit.Export(suite.ctx)
diff --git a/pkg/sdk/osapi/audit_types.go b/pkg/sdk/client/audit_types.go
similarity index 97%
rename from pkg/sdk/osapi/audit_types.go
rename to pkg/sdk/client/audit_types.go
index 3a78de99..ead6ca1e 100644
--- a/pkg/sdk/osapi/audit_types.go
+++ b/pkg/sdk/client/audit_types.go
@@ -18,12 +18,12 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"time"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// AuditEntry represents a single audit log entry.
diff --git a/pkg/sdk/osapi/audit_types_test.go b/pkg/sdk/client/audit_types_test.go
similarity index 98%
rename from pkg/sdk/osapi/audit_types_test.go
rename to pkg/sdk/client/audit_types_test.go
index 313b1ad3..e783b219 100644
--- a/pkg/sdk/osapi/audit_types_test.go
+++ b/pkg/sdk/client/audit_types_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
@@ -27,7 +27,7 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type AuditTypesTestSuite struct {
diff --git a/pkg/sdk/osapi/errors.go b/pkg/sdk/client/errors.go
similarity index 99%
rename from pkg/sdk/osapi/errors.go
rename to pkg/sdk/client/errors.go
index 508e7421..50d01207 100644
--- a/pkg/sdk/osapi/errors.go
+++ b/pkg/sdk/client/errors.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import "fmt"
diff --git a/pkg/sdk/osapi/errors_public_test.go b/pkg/sdk/client/errors_public_test.go
similarity index 79%
rename from pkg/sdk/osapi/errors_public_test.go
rename to pkg/sdk/client/errors_public_test.go
index e3f8cbc7..03a8f310 100644
--- a/pkg/sdk/osapi/errors_public_test.go
+++ b/pkg/sdk/client/errors_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"errors"
@@ -27,7 +27,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type ErrorsPublicTestSuite struct {
@@ -42,7 +42,7 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
}{
{
name: "when APIError formats correctly",
- err: &osapi.APIError{
+ err: &client.APIError{
StatusCode: 500,
Message: "something went wrong",
},
@@ -55,8 +55,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when AuthError formats correctly",
- err: &osapi.AuthError{
- APIError: osapi.APIError{
+ err: &client.AuthError{
+ APIError: client.APIError{
StatusCode: 401,
Message: "unauthorized",
},
@@ -70,8 +70,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when NotFoundError formats correctly",
- err: &osapi.NotFoundError{
- APIError: osapi.APIError{
+ err: &client.NotFoundError{
+ APIError: client.APIError{
StatusCode: 404,
Message: "resource not found",
},
@@ -85,8 +85,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when ValidationError formats correctly",
- err: &osapi.ValidationError{
- APIError: osapi.APIError{
+ err: &client.ValidationError{
+ APIError: client.APIError{
StatusCode: 400,
Message: "invalid input",
},
@@ -100,8 +100,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when ServerError formats correctly",
- err: &osapi.ServerError{
- APIError: osapi.APIError{
+ err: &client.ServerError{
+ APIError: client.APIError{
StatusCode: 500,
Message: "internal server error",
},
@@ -115,8 +115,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when ConflictError formats correctly",
- err: &osapi.ConflictError{
- APIError: osapi.APIError{
+ err: &client.ConflictError{
+ APIError: client.APIError{
StatusCode: 409,
Message: "already draining",
},
@@ -130,8 +130,8 @@ func (suite *ErrorsPublicTestSuite) TestErrorFormat() {
},
{
name: "when UnexpectedStatusError formats correctly",
- err: &osapi.UnexpectedStatusError{
- APIError: osapi.APIError{
+ err: &client.UnexpectedStatusError{
+ APIError: client.APIError{
StatusCode: 418,
Message: "unexpected status",
},
@@ -160,14 +160,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
}{
{
name: "when AuthError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.AuthError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.AuthError{
+ APIError: client.APIError{
StatusCode: 403,
Message: "forbidden",
},
}),
validateFunc: func(err error) {
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(403, target.StatusCode)
suite.Equal("forbidden", target.Message)
@@ -175,14 +175,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
},
{
name: "when NotFoundError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.NotFoundError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.NotFoundError{
+ APIError: client.APIError{
StatusCode: 404,
Message: "not found",
},
}),
validateFunc: func(err error) {
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(404, target.StatusCode)
suite.Equal("not found", target.Message)
@@ -190,14 +190,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
},
{
name: "when ValidationError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.ValidationError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ValidationError{
+ APIError: client.APIError{
StatusCode: 400,
Message: "bad request",
},
}),
validateFunc: func(err error) {
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(400, target.StatusCode)
suite.Equal("bad request", target.Message)
@@ -205,14 +205,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
},
{
name: "when ServerError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.ServerError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ServerError{
+ APIError: client.APIError{
StatusCode: 500,
Message: "server failure",
},
}),
validateFunc: func(err error) {
- var target *osapi.ServerError
+ var target *client.ServerError
suite.True(errors.As(err, &target))
suite.Equal(500, target.StatusCode)
suite.Equal("server failure", target.Message)
@@ -220,14 +220,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
},
{
name: "when ConflictError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.ConflictError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ConflictError{
+ APIError: client.APIError{
StatusCode: 409,
Message: "already draining",
},
}),
validateFunc: func(err error) {
- var target *osapi.ConflictError
+ var target *client.ConflictError
suite.True(errors.As(err, &target))
suite.Equal(409, target.StatusCode)
suite.Equal("already draining", target.Message)
@@ -235,14 +235,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsUnwrap() {
},
{
name: "when UnexpectedStatusError is unwrapped via errors.As",
- err: fmt.Errorf("wrapped: %w", &osapi.UnexpectedStatusError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.UnexpectedStatusError{
+ APIError: client.APIError{
StatusCode: 502,
Message: "bad gateway",
},
}),
validateFunc: func(err error) {
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(502, target.StatusCode)
suite.Equal("bad gateway", target.Message)
@@ -265,14 +265,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
}{
{
name: "when AuthError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.AuthError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.AuthError{
+ APIError: client.APIError{
StatusCode: 401,
Message: "unauthorized",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(401, target.StatusCode)
suite.Equal("unauthorized", target.Message)
@@ -280,14 +280,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
},
{
name: "when NotFoundError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.NotFoundError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.NotFoundError{
+ APIError: client.APIError{
StatusCode: 404,
Message: "not found",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(404, target.StatusCode)
suite.Equal("not found", target.Message)
@@ -295,14 +295,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
},
{
name: "when ValidationError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.ValidationError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ValidationError{
+ APIError: client.APIError{
StatusCode: 400,
Message: "invalid",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(400, target.StatusCode)
suite.Equal("invalid", target.Message)
@@ -310,14 +310,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
},
{
name: "when ServerError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.ServerError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ServerError{
+ APIError: client.APIError{
StatusCode: 500,
Message: "internal error",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(500, target.StatusCode)
suite.Equal("internal error", target.Message)
@@ -325,14 +325,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
},
{
name: "when ConflictError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.ConflictError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.ConflictError{
+ APIError: client.APIError{
StatusCode: 409,
Message: "conflict",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(409, target.StatusCode)
suite.Equal("conflict", target.Message)
@@ -340,14 +340,14 @@ func (suite *ErrorsPublicTestSuite) TestErrorsAsAPIError() {
},
{
name: "when UnexpectedStatusError is matchable as APIError",
- err: fmt.Errorf("wrapped: %w", &osapi.UnexpectedStatusError{
- APIError: osapi.APIError{
+ err: fmt.Errorf("wrapped: %w", &client.UnexpectedStatusError{
+ APIError: client.APIError{
StatusCode: 418,
Message: "teapot",
},
}),
validateFunc: func(err error) {
- var target *osapi.APIError
+ var target *client.APIError
suite.True(errors.As(err, &target))
suite.Equal(418, target.StatusCode)
suite.Equal("teapot", target.Message)
diff --git a/pkg/sdk/osapi/file.go b/pkg/sdk/client/file.go
similarity index 99%
rename from pkg/sdk/osapi/file.go
rename to pkg/sdk/client/file.go
index b5121121..ce934340 100644
--- a/pkg/sdk/osapi/file.go
+++ b/pkg/sdk/client/file.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"bytes"
@@ -29,7 +29,7 @@ import (
"io"
"mime/multipart"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// UploadOption configures Upload behavior.
diff --git a/pkg/sdk/osapi/file_public_test.go b/pkg/sdk/client/file_public_test.go
similarity index 84%
rename from pkg/sdk/osapi/file_public_test.go
rename to pkg/sdk/client/file_public_test.go
index 43b64084..cc3dd5f9 100644
--- a/pkg/sdk/osapi/file_public_test.go
+++ b/pkg/sdk/client/file_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"bytes"
@@ -34,7 +34,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type FilePublicTestSuite struct {
@@ -57,8 +57,8 @@ func (suite *FilePublicTestSuite) TestUpload() {
handler http.HandlerFunc
serverURL string
file io.Reader
- opts []osapi.UploadOption
- validateFunc func(*osapi.Response[osapi.FileUpload], error)
+ opts []client.UploadOption
+ validateFunc func(*client.Response[client.FileUpload], error)
}{
{
name: "when uploading new file returns result",
@@ -76,7 +76,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("nginx.conf", resp.Data.Name)
@@ -102,7 +102,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"unexpected POST"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("nginx.conf", resp.Data.Name)
@@ -113,7 +113,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
},
{
name: "when force skips pre-check and uploads",
- opts: []osapi.UploadOption{osapi.WithForce()},
+ opts: []client.UploadOption{client.WithForce()},
handler: func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodGet {
@@ -130,7 +130,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.True(resp.Data.Changed)
@@ -148,11 +148,11 @@ func (suite *FilePublicTestSuite) TestUpload() {
w.WriteHeader(http.StatusConflict)
_, _ = w.Write([]byte(`{"error":"file already exists"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ConflictError
+ var target *client.ConflictError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusConflict, target.StatusCode)
},
@@ -169,11 +169,11 @@ func (suite *FilePublicTestSuite) TestUpload() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"name is required"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -185,11 +185,11 @@ func (suite *FilePublicTestSuite) TestUpload() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -197,7 +197,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
{
name: "when client HTTP call fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
},
@@ -211,11 +211,11 @@ func (suite *FilePublicTestSuite) TestUpload() {
}
w.WriteHeader(http.StatusCreated)
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusCreated, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -227,7 +227,7 @@ func (suite *FilePublicTestSuite) TestUpload() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
},
- validateFunc: func(resp *osapi.Response[osapi.FileUpload], err error) {
+ validateFunc: func(resp *client.Response[client.FileUpload], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "read file")
@@ -252,10 +252,10 @@ func (suite *FilePublicTestSuite) TestUpload() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
file := tc.file
@@ -280,7 +280,7 @@ func (suite *FilePublicTestSuite) TestList() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.FileList], error)
+ validateFunc func(*client.Response[client.FileList], error)
}{
{
name: "when listing files returns results",
@@ -293,7 +293,7 @@ func (suite *FilePublicTestSuite) TestList() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileList], err error) {
+ validateFunc: func(resp *client.Response[client.FileList], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Files, 1)
@@ -309,11 +309,11 @@ func (suite *FilePublicTestSuite) TestList() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileList], err error) {
+ validateFunc: func(resp *client.Response[client.FileList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -321,7 +321,7 @@ func (suite *FilePublicTestSuite) TestList() {
{
name: "when client HTTP call fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.FileList], err error) {
+ validateFunc: func(resp *client.Response[client.FileList], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "list files")
@@ -332,11 +332,11 @@ func (suite *FilePublicTestSuite) TestList() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.FileList], err error) {
+ validateFunc: func(resp *client.Response[client.FileList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -361,10 +361,10 @@ func (suite *FilePublicTestSuite) TestList() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.File.List(suite.ctx)
@@ -379,7 +379,7 @@ func (suite *FilePublicTestSuite) TestGet() {
handler http.HandlerFunc
serverURL string
fileName string
- validateFunc func(*osapi.Response[osapi.FileMetadata], error)
+ validateFunc func(*client.Response[client.FileMetadata], error)
}{
{
name: "when getting file returns metadata",
@@ -393,7 +393,7 @@ func (suite *FilePublicTestSuite) TestGet() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("nginx.conf", resp.Data.Name)
@@ -410,11 +410,11 @@ func (suite *FilePublicTestSuite) TestGet() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid file name"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -427,11 +427,11 @@ func (suite *FilePublicTestSuite) TestGet() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"file not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -444,11 +444,11 @@ func (suite *FilePublicTestSuite) TestGet() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -457,7 +457,7 @@ func (suite *FilePublicTestSuite) TestGet() {
name: "when client HTTP call fails returns error",
fileName: "nginx.conf",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get file nginx.conf")
@@ -469,11 +469,11 @@ func (suite *FilePublicTestSuite) TestGet() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.FileMetadata], err error) {
+ validateFunc: func(resp *client.Response[client.FileMetadata], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -498,10 +498,10 @@ func (suite *FilePublicTestSuite) TestGet() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.File.Get(suite.ctx, tc.fileName)
@@ -516,7 +516,7 @@ func (suite *FilePublicTestSuite) TestDelete() {
handler http.HandlerFunc
serverURL string
fileName string
- validateFunc func(*osapi.Response[osapi.FileDelete], error)
+ validateFunc func(*client.Response[client.FileDelete], error)
}{
{
name: "when deleting file returns result",
@@ -528,7 +528,7 @@ func (suite *FilePublicTestSuite) TestDelete() {
[]byte(`{"name":"old.conf","deleted":true}`),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("old.conf", resp.Data.Name)
@@ -543,11 +543,11 @@ func (suite *FilePublicTestSuite) TestDelete() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid file name"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -560,11 +560,11 @@ func (suite *FilePublicTestSuite) TestDelete() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"file not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -577,11 +577,11 @@ func (suite *FilePublicTestSuite) TestDelete() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -590,7 +590,7 @@ func (suite *FilePublicTestSuite) TestDelete() {
name: "when client HTTP call fails returns error",
fileName: "old.conf",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "delete file old.conf")
@@ -602,11 +602,11 @@ func (suite *FilePublicTestSuite) TestDelete() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.FileDelete], err error) {
+ validateFunc: func(resp *client.Response[client.FileDelete], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -631,10 +631,10 @@ func (suite *FilePublicTestSuite) TestDelete() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.File.Delete(suite.ctx, tc.fileName)
@@ -657,7 +657,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
handler http.HandlerFunc
serverURL string
file io.Reader
- validateFunc func(*osapi.Response[osapi.FileChanged], error)
+ validateFunc func(*client.Response[client.FileChanged], error)
}{
{
name: "when file does not exist returns changed true",
@@ -666,7 +666,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"file not found"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) {
+ validateFunc: func(resp *client.Response[client.FileChanged], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.True(resp.Data.Changed)
@@ -684,7 +684,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
contentSHA,
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) {
+ validateFunc: func(resp *client.Response[client.FileChanged], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.False(resp.Data.Changed)
@@ -702,7 +702,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
contentSHA,
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) {
+ validateFunc: func(resp *client.Response[client.FileChanged], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.True(resp.Data.Changed)
@@ -716,7 +716,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) {
+ validateFunc: func(resp *client.Response[client.FileChanged], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "check file nginx.conf")
@@ -728,7 +728,7 @@ func (suite *FilePublicTestSuite) TestChanged() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.FileChanged], err error) {
+ validateFunc: func(resp *client.Response[client.FileChanged], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "read file")
@@ -753,10 +753,10 @@ func (suite *FilePublicTestSuite) TestChanged() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
file := tc.file
diff --git a/pkg/sdk/osapi/file_types.go b/pkg/sdk/client/file_types.go
similarity index 98%
rename from pkg/sdk/osapi/file_types.go
rename to pkg/sdk/client/file_types.go
index 1ba65683..f060e9b3 100644
--- a/pkg/sdk/osapi/file_types.go
+++ b/pkg/sdk/client/file_types.go
@@ -18,9 +18,9 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
-import "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+import "github.com/retr0h/osapi/pkg/sdk/client/gen"
// FileUpload represents a successfully uploaded file.
type FileUpload struct {
diff --git a/pkg/sdk/osapi/file_types_test.go b/pkg/sdk/client/file_types_test.go
similarity index 99%
rename from pkg/sdk/osapi/file_types_test.go
rename to pkg/sdk/client/file_types_test.go
index 1e31370b..3d2bc1c8 100644
--- a/pkg/sdk/osapi/file_types_test.go
+++ b/pkg/sdk/client/file_types_test.go
@@ -18,14 +18,14 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type FileTypesTestSuite struct {
diff --git a/pkg/sdk/osapi/gen/cfg.yaml b/pkg/sdk/client/gen/cfg.yaml
similarity index 100%
rename from pkg/sdk/osapi/gen/cfg.yaml
rename to pkg/sdk/client/gen/cfg.yaml
diff --git a/pkg/sdk/osapi/gen/client.gen.go b/pkg/sdk/client/gen/client.gen.go
similarity index 98%
rename from pkg/sdk/osapi/gen/client.gen.go
rename to pkg/sdk/client/gen/client.gen.go
index 3a78e45c..f7bada7a 100644
--- a/pkg/sdk/osapi/gen/client.gen.go
+++ b/pkg/sdk/client/gen/client.gen.go
@@ -901,18 +901,6 @@ type PingResponse struct {
PacketsSent *int `json:"packets_sent,omitempty"`
}
-// QueueStatsResponse defines model for QueueStatsResponse.
-type QueueStatsResponse struct {
- // DlqCount Number of jobs in the dead letter queue.
- DlqCount *int `json:"dlq_count,omitempty"`
-
- // StatusCounts Count of jobs by status.
- StatusCounts *map[string]int `json:"status_counts,omitempty"`
-
- // TotalJobs Total number of jobs in the queue.
- TotalJobs *int `json:"total_jobs,omitempty"`
-}
-
// ReadyResponse defines model for ReadyResponse.
type ReadyResponse struct {
// Error Error message when not ready.
@@ -1228,9 +1216,6 @@ type ClientInterface interface {
PostJob(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
- // GetJobStatus request
- GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
-
// DeleteJobByID request
DeleteJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error)
@@ -1504,18 +1489,6 @@ func (c *Client) PostJob(ctx context.Context, body PostJobJSONRequestBody, reqEd
return c.Client.Do(req)
}
-func (c *Client) GetJobStatus(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
- req, err := NewGetJobStatusRequest(c.Server)
- if err != nil {
- return nil, err
- }
- req = req.WithContext(ctx)
- if err := c.applyEditors(ctx, req, reqEditors); err != nil {
- return nil, err
- }
- return c.Client.Do(req)
-}
-
func (c *Client) DeleteJobByID(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) {
req, err := NewDeleteJobByIDRequest(c.Server, id)
if err != nil {
@@ -2419,33 +2392,6 @@ func NewPostJobRequestWithBody(server string, contentType string, body io.Reader
return req, nil
}
-// NewGetJobStatusRequest generates requests for GetJobStatus
-func NewGetJobStatusRequest(server string) (*http.Request, error) {
- var err error
-
- serverURL, err := url.Parse(server)
- if err != nil {
- return nil, err
- }
-
- operationPath := fmt.Sprintf("/job/status")
- if operationPath[0] == '/' {
- operationPath = "." + operationPath
- }
-
- queryURL, err := serverURL.Parse(operationPath)
- if err != nil {
- return nil, err
- }
-
- req, err := http.NewRequest("GET", queryURL.String(), nil)
- if err != nil {
- return nil, err
- }
-
- return req, nil
-}
-
// NewDeleteJobByIDRequest generates requests for DeleteJobByID
func NewDeleteJobByIDRequest(server string, id openapi_types.UUID) (*http.Request, error) {
var err error
@@ -3242,9 +3188,6 @@ type ClientWithResponsesInterface interface {
PostJobWithResponse(ctx context.Context, body PostJobJSONRequestBody, reqEditors ...RequestEditorFn) (*PostJobResponse, error)
- // GetJobStatusWithResponse request
- GetJobStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobStatusResponse, error)
-
// DeleteJobByIDWithResponse request
DeleteJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error)
@@ -3729,31 +3672,6 @@ func (r PostJobResponse) StatusCode() int {
return 0
}
-type GetJobStatusResponse struct {
- Body []byte
- HTTPResponse *http.Response
- JSON200 *QueueStatsResponse
- JSON401 *ErrorResponse
- JSON403 *ErrorResponse
- JSON500 *ErrorResponse
-}
-
-// Status returns HTTPResponse.Status
-func (r GetJobStatusResponse) Status() string {
- if r.HTTPResponse != nil {
- return r.HTTPResponse.Status
- }
- return http.StatusText(0)
-}
-
-// StatusCode returns HTTPResponse.StatusCode
-func (r GetJobStatusResponse) StatusCode() int {
- if r.HTTPResponse != nil {
- return r.HTTPResponse.StatusCode
- }
- return 0
-}
-
type DeleteJobByIDResponse struct {
Body []byte
HTTPResponse *http.Response
@@ -4372,15 +4290,6 @@ func (c *ClientWithResponses) PostJobWithResponse(ctx context.Context, body Post
return ParsePostJobResponse(rsp)
}
-// GetJobStatusWithResponse request returning *GetJobStatusResponse
-func (c *ClientWithResponses) GetJobStatusWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetJobStatusResponse, error) {
- rsp, err := c.GetJobStatus(ctx, reqEditors...)
- if err != nil {
- return nil, err
- }
- return ParseGetJobStatusResponse(rsp)
-}
-
// DeleteJobByIDWithResponse request returning *DeleteJobByIDResponse
func (c *ClientWithResponses) DeleteJobByIDWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeleteJobByIDResponse, error) {
rsp, err := c.DeleteJobByID(ctx, id, reqEditors...)
@@ -5432,53 +5341,6 @@ func ParsePostJobResponse(rsp *http.Response) (*PostJobResponse, error) {
return response, nil
}
-// ParseGetJobStatusResponse parses an HTTP response from a GetJobStatusWithResponse call
-func ParseGetJobStatusResponse(rsp *http.Response) (*GetJobStatusResponse, error) {
- bodyBytes, err := io.ReadAll(rsp.Body)
- defer func() { _ = rsp.Body.Close() }()
- if err != nil {
- return nil, err
- }
-
- response := &GetJobStatusResponse{
- Body: bodyBytes,
- HTTPResponse: rsp,
- }
-
- switch {
- case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
- var dest QueueStatsResponse
- if err := json.Unmarshal(bodyBytes, &dest); err != nil {
- return nil, err
- }
- response.JSON200 = &dest
-
- case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401:
- var dest ErrorResponse
- if err := json.Unmarshal(bodyBytes, &dest); err != nil {
- return nil, err
- }
- response.JSON401 = &dest
-
- case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
- var dest ErrorResponse
- if err := json.Unmarshal(bodyBytes, &dest); err != nil {
- return nil, err
- }
- response.JSON403 = &dest
-
- case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500:
- var dest ErrorResponse
- if err := json.Unmarshal(bodyBytes, &dest); err != nil {
- return nil, err
- }
- response.JSON500 = &dest
-
- }
-
- return response, nil
-}
-
// ParseDeleteJobByIDResponse parses an HTTP response from a DeleteJobByIDWithResponse call
func ParseDeleteJobByIDResponse(rsp *http.Response) (*DeleteJobByIDResponse, error) {
bodyBytes, err := io.ReadAll(rsp.Body)
diff --git a/pkg/sdk/osapi/gen/generate.go b/pkg/sdk/client/gen/generate.go
similarity index 100%
rename from pkg/sdk/osapi/gen/generate.go
rename to pkg/sdk/client/gen/generate.go
diff --git a/pkg/sdk/osapi/health.go b/pkg/sdk/client/health.go
similarity index 98%
rename from pkg/sdk/osapi/health.go
rename to pkg/sdk/client/health.go
index 723f7544..76cd1b85 100644
--- a/pkg/sdk/osapi/health.go
+++ b/pkg/sdk/client/health.go
@@ -18,13 +18,13 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// HealthService provides health check operations.
diff --git a/pkg/sdk/osapi/health_public_test.go b/pkg/sdk/client/health_public_test.go
similarity index 82%
rename from pkg/sdk/osapi/health_public_test.go
rename to pkg/sdk/client/health_public_test.go
index aff3f455..fa953679 100644
--- a/pkg/sdk/osapi/health_public_test.go
+++ b/pkg/sdk/client/health_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type HealthPublicTestSuite struct {
@@ -61,7 +61,7 @@ func (suite *HealthPublicTestSuite) TestLiveness() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.HealthStatus], error)
+ validateFunc func(*client.Response[client.HealthStatus], error)
}{
{
name: "when checking liveness returns health status",
@@ -70,7 +70,7 @@ func (suite *HealthPublicTestSuite) TestLiveness() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) {
+ validateFunc: func(resp *client.Response[client.HealthStatus], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("ok", resp.Data.Status)
@@ -79,7 +79,7 @@ func (suite *HealthPublicTestSuite) TestLiveness() {
{
name: "when client HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) {
+ validateFunc: func(resp *client.Response[client.HealthStatus], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "health liveness")
@@ -91,11 +91,11 @@ func (suite *HealthPublicTestSuite) TestLiveness() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.HealthStatus], err error) {
+ validateFunc: func(resp *client.Response[client.HealthStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -105,10 +105,10 @@ func (suite *HealthPublicTestSuite) TestLiveness() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- sut := osapi.New(
+ sut := client.New(
suite.runner(tc.handler, tc.serverURL),
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Health.Liveness(suite.ctx)
@@ -122,7 +122,7 @@ func (suite *HealthPublicTestSuite) TestReady() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.ReadyStatus], error)
+ validateFunc func(*client.Response[client.ReadyStatus], error)
}{
{
name: "when checking readiness returns ready status",
@@ -131,7 +131,7 @@ func (suite *HealthPublicTestSuite) TestReady() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ready"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("ready", resp.Data.Status)
@@ -141,7 +141,7 @@ func (suite *HealthPublicTestSuite) TestReady() {
{
name: "when client HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "health ready")
@@ -153,11 +153,11 @@ func (suite *HealthPublicTestSuite) TestReady() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -169,11 +169,11 @@ func (suite *HealthPublicTestSuite) TestReady() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
}),
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusInternalServerError, target.StatusCode)
suite.Contains(target.Message, "unexpected status")
@@ -186,7 +186,7 @@ func (suite *HealthPublicTestSuite) TestReady() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"not_ready","error":"nats down"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("not_ready", resp.Data.Status)
@@ -200,11 +200,11 @@ func (suite *HealthPublicTestSuite) TestReady() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusServiceUnavailable)
}),
- validateFunc: func(resp *osapi.Response[osapi.ReadyStatus], err error) {
+ validateFunc: func(resp *client.Response[client.ReadyStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusServiceUnavailable, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -214,10 +214,10 @@ func (suite *HealthPublicTestSuite) TestReady() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- sut := osapi.New(
+ sut := client.New(
suite.runner(tc.handler, tc.serverURL),
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Health.Ready(suite.ctx)
@@ -231,7 +231,7 @@ func (suite *HealthPublicTestSuite) TestStatus() {
name string
handler http.HandlerFunc
serverURL string
- validateFunc func(*osapi.Response[osapi.SystemStatus], error)
+ validateFunc func(*client.Response[client.SystemStatus], error)
}{
{
name: "when checking status returns system status",
@@ -240,7 +240,7 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok","version":"1.0.0","uptime":"1h"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("ok", resp.Data.Status)
@@ -252,7 +252,7 @@ func (suite *HealthPublicTestSuite) TestStatus() {
{
name: "when client HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "health status")
@@ -264,11 +264,11 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -280,11 +280,11 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTeapot)
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusTeapot, target.StatusCode)
suite.Contains(target.Message, "unexpected status")
@@ -297,7 +297,7 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"status":"degraded","version":"1.0.0","uptime":"1h"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("degraded", resp.Data.Status)
@@ -310,11 +310,11 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusServiceUnavailable)
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusServiceUnavailable, target.StatusCode)
suite.Contains(target.Message, "nil response body")
@@ -327,11 +327,11 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusUnauthorized, target.StatusCode)
},
@@ -343,11 +343,11 @@ func (suite *HealthPublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
}),
- validateFunc: func(resp *osapi.Response[osapi.SystemStatus], err error) {
+ validateFunc: func(resp *client.Response[client.SystemStatus], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -356,10 +356,10 @@ func (suite *HealthPublicTestSuite) TestStatus() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- sut := osapi.New(
+ sut := client.New(
suite.runner(tc.handler, tc.serverURL),
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Health.Status(suite.ctx)
diff --git a/pkg/sdk/osapi/health_types.go b/pkg/sdk/client/health_types.go
similarity index 98%
rename from pkg/sdk/osapi/health_types.go
rename to pkg/sdk/client/health_types.go
index 074e3faa..3a3dde13 100644
--- a/pkg/sdk/osapi/health_types.go
+++ b/pkg/sdk/client/health_types.go
@@ -18,9 +18,9 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
-import "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+import "github.com/retr0h/osapi/pkg/sdk/client/gen"
// HealthStatus represents a liveness check response.
type HealthStatus struct {
diff --git a/pkg/sdk/osapi/health_types_test.go b/pkg/sdk/client/health_types_test.go
similarity index 99%
rename from pkg/sdk/osapi/health_types_test.go
rename to pkg/sdk/client/health_types_test.go
index 0f68e254..9c75729a 100644
--- a/pkg/sdk/osapi/health_types_test.go
+++ b/pkg/sdk/client/health_types_test.go
@@ -18,14 +18,14 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type HealthTypesTestSuite struct {
diff --git a/pkg/sdk/osapi/job.go b/pkg/sdk/client/job.go
similarity index 89%
rename from pkg/sdk/osapi/job.go
rename to pkg/sdk/client/job.go
index 8965cc50..11d24c20 100644
--- a/pkg/sdk/osapi/job.go
+++ b/pkg/sdk/client/job.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
@@ -26,7 +26,7 @@ import (
"github.com/google/uuid"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// JobService provides job queue operations.
@@ -167,29 +167,6 @@ func (s *JobService) List(
return NewResponse(jobListFromGen(resp.JSON200), resp.Body), nil
}
-// QueueStats retrieves job queue statistics.
-func (s *JobService) QueueStats(
- ctx context.Context,
-) (*Response[QueueStats], error) {
- resp, err := s.client.GetJobStatusWithResponse(ctx)
- if err != nil {
- return nil, fmt.Errorf("queue stats: %w", err)
- }
-
- if err := checkError(resp.StatusCode(), resp.JSON401, resp.JSON403, resp.JSON500); err != nil {
- return nil, err
- }
-
- if resp.JSON200 == nil {
- return nil, &UnexpectedStatusError{APIError{
- StatusCode: resp.StatusCode(),
- Message: "nil response body",
- }}
- }
-
- return NewResponse(queueStatsFromGen(resp.JSON200), resp.Body), nil
-}
-
// Retry retries a failed job by ID, optionally on a different target.
func (s *JobService) Retry(
ctx context.Context,
diff --git a/pkg/sdk/osapi/job_public_test.go b/pkg/sdk/client/job_public_test.go
similarity index 74%
rename from pkg/sdk/osapi/job_public_test.go
rename to pkg/sdk/client/job_public_test.go
index af3c5978..87d25d25 100644
--- a/pkg/sdk/osapi/job_public_test.go
+++ b/pkg/sdk/client/job_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type JobPublicTestSuite struct {
@@ -50,7 +50,7 @@ func (suite *JobPublicTestSuite) TestCreate() {
serverURL string
operation map[string]interface{}
target string
- validateFunc func(*osapi.Response[osapi.JobCreated], error)
+ validateFunc func(*client.Response[client.JobCreated], error)
}{
{
name: "when creating job returns response",
@@ -65,7 +65,7 @@ func (suite *JobPublicTestSuite) TestCreate() {
},
operation: map[string]interface{}{"type": "system.hostname.get"},
target: "_any",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.JobID)
@@ -81,11 +81,11 @@ func (suite *JobPublicTestSuite) TestCreate() {
},
operation: map[string]interface{}{},
target: "_any",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -95,7 +95,7 @@ func (suite *JobPublicTestSuite) TestCreate() {
serverURL: "http://127.0.0.1:0",
operation: map[string]interface{}{},
target: "_any",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "create job")
@@ -108,11 +108,11 @@ func (suite *JobPublicTestSuite) TestCreate() {
},
operation: map[string]interface{}{},
target: "_any",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Contains(target.Message, "nil response body")
},
@@ -134,10 +134,10 @@ func (suite *JobPublicTestSuite) TestCreate() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Job.Create(suite.ctx, tc.operation, tc.target)
@@ -152,7 +152,7 @@ func (suite *JobPublicTestSuite) TestGet() {
handler http.HandlerFunc
serverURL string
id string
- validateFunc func(*osapi.Response[osapi.JobDetail], error)
+ validateFunc func(*client.Response[client.JobDetail], error)
}{
{
name: "when valid UUID returns response",
@@ -166,7 +166,7 @@ func (suite *JobPublicTestSuite) TestGet() {
)
},
id: "550e8400-e29b-41d4-a716-446655440000",
- validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) {
+ validateFunc: func(resp *client.Response[client.JobDetail], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.ID)
@@ -185,7 +185,7 @@ func (suite *JobPublicTestSuite) TestGet() {
)
},
id: "not-a-uuid",
- validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) {
+ validateFunc: func(resp *client.Response[client.JobDetail], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "invalid job ID")
@@ -195,7 +195,7 @@ func (suite *JobPublicTestSuite) TestGet() {
name: "when HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
id: "00000000-0000-0000-0000-000000000000",
- validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) {
+ validateFunc: func(resp *client.Response[client.JobDetail], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get job")
@@ -207,11 +207,11 @@ func (suite *JobPublicTestSuite) TestGet() {
w.WriteHeader(http.StatusOK)
},
id: "00000000-0000-0000-0000-000000000000",
- validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) {
+ validateFunc: func(resp *client.Response[client.JobDetail], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Contains(target.Message, "nil response body")
},
@@ -224,11 +224,11 @@ func (suite *JobPublicTestSuite) TestGet() {
_, _ = w.Write([]byte(`{"error":"job not found"}`))
},
id: "550e8400-e29b-41d4-a716-446655440000",
- validateFunc: func(resp *osapi.Response[osapi.JobDetail], err error) {
+ validateFunc: func(resp *client.Response[client.JobDetail], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
suite.Equal("job not found", target.Message)
@@ -251,10 +251,10 @@ func (suite *JobPublicTestSuite) TestGet() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Job.Get(suite.ctx, tc.id)
@@ -312,7 +312,7 @@ func (suite *JobPublicTestSuite) TestDelete() {
validateFunc: func(err error) {
suite.Error(err)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
suite.Equal("job not found", target.Message)
@@ -335,10 +335,10 @@ func (suite *JobPublicTestSuite) TestDelete() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
err := sut.Job.Delete(suite.ctx, tc.id)
@@ -352,8 +352,8 @@ func (suite *JobPublicTestSuite) TestList() {
name string
handler http.HandlerFunc
serverURL string
- params osapi.ListParams
- validateFunc func(*osapi.Response[osapi.JobList], error)
+ params client.ListParams
+ validateFunc func(*client.Response[client.JobList], error)
}{
{
name: "when no filters returns response",
@@ -362,8 +362,8 @@ func (suite *JobPublicTestSuite) TestList() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[],"total_items":0}`))
},
- params: osapi.ListParams{},
- validateFunc: func(resp *osapi.Response[osapi.JobList], err error) {
+ params: client.ListParams{},
+ validateFunc: func(resp *client.Response[client.JobList], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal(0, resp.Data.TotalItems)
@@ -377,12 +377,12 @@ func (suite *JobPublicTestSuite) TestList() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"items":[],"total_items":0}`))
},
- params: osapi.ListParams{
+ params: client.ListParams{
Status: "completed",
Limit: 10,
Offset: 5,
},
- validateFunc: func(resp *osapi.Response[osapi.JobList], err error) {
+ validateFunc: func(resp *client.Response[client.JobList], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
@@ -390,8 +390,8 @@ func (suite *JobPublicTestSuite) TestList() {
{
name: "when HTTP request fails returns error",
serverURL: "http://127.0.0.1:0",
- params: osapi.ListParams{},
- validateFunc: func(resp *osapi.Response[osapi.JobList], err error) {
+ params: client.ListParams{},
+ validateFunc: func(resp *client.Response[client.JobList], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "list jobs")
@@ -404,12 +404,12 @@ func (suite *JobPublicTestSuite) TestList() {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
},
- params: osapi.ListParams{},
- validateFunc: func(resp *osapi.Response[osapi.JobList], err error) {
+ params: client.ListParams{},
+ validateFunc: func(resp *client.Response[client.JobList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusUnauthorized, target.StatusCode)
},
@@ -419,12 +419,12 @@ func (suite *JobPublicTestSuite) TestList() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- params: osapi.ListParams{},
- validateFunc: func(resp *osapi.Response[osapi.JobList], err error) {
+ params: client.ListParams{},
+ validateFunc: func(resp *client.Response[client.JobList], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Contains(target.Message, "nil response body")
},
@@ -446,10 +446,10 @@ func (suite *JobPublicTestSuite) TestList() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Job.List(suite.ctx, tc.params)
@@ -458,94 +458,6 @@ func (suite *JobPublicTestSuite) TestList() {
}
}
-func (suite *JobPublicTestSuite) TestQueueStats() {
- tests := []struct {
- name string
- handler http.HandlerFunc
- serverURL string
- validateFunc func(*osapi.Response[osapi.QueueStats], error)
- }{
- {
- name: "when requesting queue stats returns response",
- handler: func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write([]byte(`{"total_jobs":5}`))
- },
- validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) {
- suite.NoError(err)
- suite.NotNil(resp)
- suite.Equal(5, resp.Data.TotalJobs)
- },
- },
- {
- name: "when HTTP request fails returns error",
- serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) {
- suite.Error(err)
- suite.Nil(resp)
- suite.Contains(err.Error(), "queue stats")
- },
- },
- {
- name: "when server returns 401 returns AuthError",
- handler: func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusUnauthorized)
- _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
- },
- validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) {
- suite.Error(err)
- suite.Nil(resp)
-
- var target *osapi.AuthError
- suite.True(errors.As(err, &target))
- suite.Equal(http.StatusUnauthorized, target.StatusCode)
- },
- },
- {
- name: "when server returns 200 with empty body returns UnexpectedStatusError",
- handler: func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- },
- validateFunc: func(resp *osapi.Response[osapi.QueueStats], err error) {
- suite.Error(err)
- suite.Nil(resp)
-
- var target *osapi.UnexpectedStatusError
- suite.True(errors.As(err, &target))
- suite.Contains(target.Message, "nil response body")
- },
- },
- }
-
- for _, tc := range tests {
- suite.Run(tc.name, func() {
- var (
- serverURL string
- server *httptest.Server
- )
-
- if tc.serverURL != "" {
- serverURL = tc.serverURL
- } else {
- server = httptest.NewServer(tc.handler)
- defer server.Close()
- serverURL = server.URL
- }
-
- sut := osapi.New(
- serverURL,
- "test-token",
- osapi.WithLogger(slog.Default()),
- )
-
- resp, err := sut.Job.QueueStats(suite.ctx)
- tc.validateFunc(resp, err)
- })
- }
-}
-
func (suite *JobPublicTestSuite) TestRetry() {
tests := []struct {
name string
@@ -553,7 +465,7 @@ func (suite *JobPublicTestSuite) TestRetry() {
serverURL string
id string
target string
- validateFunc func(*osapi.Response[osapi.JobCreated], error)
+ validateFunc func(*client.Response[client.JobCreated], error)
}{
{
name: "when valid UUID with empty target returns response",
@@ -568,7 +480,7 @@ func (suite *JobPublicTestSuite) TestRetry() {
},
id: "550e8400-e29b-41d4-a716-446655440000",
target: "",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("550e8400-e29b-41d4-a716-446655440000", resp.Data.JobID)
@@ -588,7 +500,7 @@ func (suite *JobPublicTestSuite) TestRetry() {
},
id: "550e8400-e29b-41d4-a716-446655440000",
target: "web-01",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
@@ -606,7 +518,7 @@ func (suite *JobPublicTestSuite) TestRetry() {
},
id: "not-a-uuid",
target: "",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "invalid job ID")
@@ -617,7 +529,7 @@ func (suite *JobPublicTestSuite) TestRetry() {
serverURL: "http://127.0.0.1:0",
id: "00000000-0000-0000-0000-000000000000",
target: "",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "retry job")
@@ -632,11 +544,11 @@ func (suite *JobPublicTestSuite) TestRetry() {
},
id: "00000000-0000-0000-0000-000000000000",
target: "",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.NotFoundError
+ var target *client.NotFoundError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusNotFound, target.StatusCode)
},
@@ -648,11 +560,11 @@ func (suite *JobPublicTestSuite) TestRetry() {
},
id: "00000000-0000-0000-0000-000000000000",
target: "",
- validateFunc: func(resp *osapi.Response[osapi.JobCreated], err error) {
+ validateFunc: func(resp *client.Response[client.JobCreated], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Contains(target.Message, "nil response body")
},
@@ -674,10 +586,10 @@ func (suite *JobPublicTestSuite) TestRetry() {
serverURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Job.Retry(suite.ctx, tc.id, tc.target)
diff --git a/pkg/sdk/osapi/job_types.go b/pkg/sdk/client/job_types.go
similarity index 89%
rename from pkg/sdk/osapi/job_types.go
rename to pkg/sdk/client/job_types.go
index 6d3ee5a8..77898766 100644
--- a/pkg/sdk/osapi/job_types.go
+++ b/pkg/sdk/client/job_types.go
@@ -18,10 +18,10 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// JobCreated represents a newly created job response.
@@ -69,13 +69,6 @@ type JobList struct {
StatusCounts map[string]int
}
-// QueueStats represents job queue statistics.
-type QueueStats struct {
- TotalJobs int
- DlqCount int
- StatusCounts map[string]int
-}
-
// jobCreatedFromGen converts a gen.CreateJobResponse to a JobCreated.
func jobCreatedFromGen(
g *gen.CreateJobResponse,
@@ -239,24 +232,3 @@ func jobListFromGen(
return jl
}
-
-// queueStatsFromGen converts a gen.QueueStatsResponse to QueueStats.
-func queueStatsFromGen(
- g *gen.QueueStatsResponse,
-) QueueStats {
- qs := QueueStats{}
-
- if g.TotalJobs != nil {
- qs.TotalJobs = *g.TotalJobs
- }
-
- if g.DlqCount != nil {
- qs.DlqCount = *g.DlqCount
- }
-
- if g.StatusCounts != nil {
- qs.StatusCounts = *g.StatusCounts
- }
-
- return qs
-}
diff --git a/pkg/sdk/osapi/job_types_test.go b/pkg/sdk/client/job_types_test.go
similarity index 87%
rename from pkg/sdk/osapi/job_types_test.go
rename to pkg/sdk/client/job_types_test.go
index 430c99ab..15950eb2 100644
--- a/pkg/sdk/osapi/job_types_test.go
+++ b/pkg/sdk/client/job_types_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
@@ -27,7 +27,7 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type JobTypesTestSuite struct {
@@ -304,58 +304,6 @@ func (suite *JobTypesTestSuite) TestJobListFromGen() {
}
}
-func (suite *JobTypesTestSuite) TestQueueStatsFromGen() {
- tests := []struct {
- name string
- input *gen.QueueStatsResponse
- validateFunc func(QueueStats)
- }{
- {
- name: "when all fields are populated",
- input: func() *gen.QueueStatsResponse {
- totalJobs := 100
- dlqCount := 5
- statusCounts := map[string]int{
- "pending": 30,
- "completed": 60,
- "failed": 10,
- }
-
- return &gen.QueueStatsResponse{
- TotalJobs: &totalJobs,
- DlqCount: &dlqCount,
- StatusCounts: &statusCounts,
- }
- }(),
- validateFunc: func(qs QueueStats) {
- suite.Equal(100, qs.TotalJobs)
- suite.Equal(5, qs.DlqCount)
- suite.Equal(map[string]int{
- "pending": 30,
- "completed": 60,
- "failed": 10,
- }, qs.StatusCounts)
- },
- },
- {
- name: "when all fields are nil",
- input: &gen.QueueStatsResponse{},
- validateFunc: func(qs QueueStats) {
- suite.Equal(0, qs.TotalJobs)
- suite.Equal(0, qs.DlqCount)
- suite.Nil(qs.StatusCounts)
- },
- },
- }
-
- for _, tc := range tests {
- suite.Run(tc.name, func() {
- result := queueStatsFromGen(tc.input)
- tc.validateFunc(result)
- })
- }
-}
-
func TestJobTypesTestSuite(t *testing.T) {
suite.Run(t, new(JobTypesTestSuite))
}
diff --git a/pkg/sdk/osapi/metrics.go b/pkg/sdk/client/metrics.go
similarity index 97%
rename from pkg/sdk/osapi/metrics.go
rename to pkg/sdk/client/metrics.go
index 2af459a0..192a6ecc 100644
--- a/pkg/sdk/osapi/metrics.go
+++ b/pkg/sdk/client/metrics.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
@@ -27,7 +27,7 @@ import (
"net/http"
"strings"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// MetricsService provides Prometheus metrics access.
diff --git a/pkg/sdk/osapi/metrics_public_test.go b/pkg/sdk/client/metrics_public_test.go
similarity index 96%
rename from pkg/sdk/osapi/metrics_public_test.go
rename to pkg/sdk/client/metrics_public_test.go
index ec31309c..d0089a7d 100644
--- a/pkg/sdk/osapi/metrics_public_test.go
+++ b/pkg/sdk/client/metrics_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -29,7 +29,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type MetricsPublicTestSuite struct {
@@ -119,10 +119,10 @@ func (suite *MetricsPublicTestSuite) TestGet() {
targetURL = server.URL
}
- sut := osapi.New(
+ sut := client.New(
targetURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
//nolint:staticcheck // nil context intentionally triggers NewRequestWithContext error
diff --git a/pkg/sdk/osapi/metrics_test.go b/pkg/sdk/client/metrics_test.go
similarity index 99%
rename from pkg/sdk/osapi/metrics_test.go
rename to pkg/sdk/client/metrics_test.go
index 9560777a..22bbb50c 100644
--- a/pkg/sdk/osapi/metrics_test.go
+++ b/pkg/sdk/client/metrics_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
diff --git a/pkg/sdk/osapi/node.go b/pkg/sdk/client/node.go
similarity index 99%
rename from pkg/sdk/osapi/node.go
rename to pkg/sdk/client/node.go
index 655370ca..3ef48887 100644
--- a/pkg/sdk/osapi/node.go
+++ b/pkg/sdk/client/node.go
@@ -18,13 +18,13 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"context"
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// NodeService provides node management operations.
diff --git a/pkg/sdk/osapi/node_public_test.go b/pkg/sdk/client/node_public_test.go
similarity index 79%
rename from pkg/sdk/osapi/node_public_test.go
rename to pkg/sdk/client/node_public_test.go
index 4df60658..6839581f 100644
--- a/pkg/sdk/osapi/node_public_test.go
+++ b/pkg/sdk/client/node_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"context"
@@ -30,7 +30,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type NodePublicTestSuite struct {
@@ -49,7 +49,7 @@ func (suite *NodePublicTestSuite) TestHostname() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.HostnameResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.HostnameResult]], error)
}{
{
name: "when requesting hostname returns results",
@@ -63,7 +63,7 @@ func (suite *NodePublicTestSuite) TestHostname() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.HostnameResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("00000000-0000-0000-0000-000000000001", resp.Data.JobID)
@@ -79,11 +79,11 @@ func (suite *NodePublicTestSuite) TestHostname() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.HostnameResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -92,7 +92,7 @@ func (suite *NodePublicTestSuite) TestHostname() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.HostnameResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get hostname")
@@ -104,11 +104,11 @@ func (suite *NodePublicTestSuite) TestHostname() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.HostnameResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.HostnameResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -133,10 +133,10 @@ func (suite *NodePublicTestSuite) TestHostname() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Hostname(suite.ctx, tc.target)
@@ -151,7 +151,7 @@ func (suite *NodePublicTestSuite) TestStatus() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.NodeStatus]], error)
+ validateFunc func(*client.Response[client.Collection[client.NodeStatus]], error)
}{
{
name: "when requesting status returns results",
@@ -161,7 +161,7 @@ func (suite *NodePublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[{"hostname":"web-01"}]}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.NodeStatus]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -176,11 +176,11 @@ func (suite *NodePublicTestSuite) TestStatus() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.NodeStatus]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -189,7 +189,7 @@ func (suite *NodePublicTestSuite) TestStatus() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.NodeStatus]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get status")
@@ -201,11 +201,11 @@ func (suite *NodePublicTestSuite) TestStatus() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.NodeStatus]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.NodeStatus]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -230,10 +230,10 @@ func (suite *NodePublicTestSuite) TestStatus() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Status(suite.ctx, tc.target)
@@ -248,7 +248,7 @@ func (suite *NodePublicTestSuite) TestDisk() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.DiskResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.DiskResult]], error)
}{
{
name: "when requesting disk returns results",
@@ -258,7 +258,7 @@ func (suite *NodePublicTestSuite) TestDisk() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[{"hostname":"disk-host"}]}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DiskResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -273,11 +273,11 @@ func (suite *NodePublicTestSuite) TestDisk() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DiskResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -286,7 +286,7 @@ func (suite *NodePublicTestSuite) TestDisk() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DiskResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get disk")
@@ -298,11 +298,11 @@ func (suite *NodePublicTestSuite) TestDisk() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DiskResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DiskResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -327,10 +327,10 @@ func (suite *NodePublicTestSuite) TestDisk() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Disk(suite.ctx, tc.target)
@@ -345,7 +345,7 @@ func (suite *NodePublicTestSuite) TestMemory() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.MemoryResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.MemoryResult]], error)
}{
{
name: "when requesting memory returns results",
@@ -355,7 +355,7 @@ func (suite *NodePublicTestSuite) TestMemory() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[{"hostname":"mem-host"}]}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.MemoryResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -370,11 +370,11 @@ func (suite *NodePublicTestSuite) TestMemory() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.MemoryResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -383,7 +383,7 @@ func (suite *NodePublicTestSuite) TestMemory() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.MemoryResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get memory")
@@ -395,11 +395,11 @@ func (suite *NodePublicTestSuite) TestMemory() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.MemoryResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.MemoryResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -424,10 +424,10 @@ func (suite *NodePublicTestSuite) TestMemory() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Memory(suite.ctx, tc.target)
@@ -442,7 +442,7 @@ func (suite *NodePublicTestSuite) TestLoad() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.LoadResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.LoadResult]], error)
}{
{
name: "when requesting load returns results",
@@ -452,7 +452,7 @@ func (suite *NodePublicTestSuite) TestLoad() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[{"hostname":"load-host"}]}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.LoadResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -467,11 +467,11 @@ func (suite *NodePublicTestSuite) TestLoad() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.LoadResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -480,7 +480,7 @@ func (suite *NodePublicTestSuite) TestLoad() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.LoadResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get load")
@@ -492,11 +492,11 @@ func (suite *NodePublicTestSuite) TestLoad() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.LoadResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.LoadResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -521,10 +521,10 @@ func (suite *NodePublicTestSuite) TestLoad() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Load(suite.ctx, tc.target)
@@ -539,7 +539,7 @@ func (suite *NodePublicTestSuite) TestOS() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.OSInfoResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.OSInfoResult]], error)
}{
{
name: "when requesting OS info returns results",
@@ -549,7 +549,7 @@ func (suite *NodePublicTestSuite) TestOS() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"results":[{"hostname":"os-host"}]}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.OSInfoResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -564,11 +564,11 @@ func (suite *NodePublicTestSuite) TestOS() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.OSInfoResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -577,7 +577,7 @@ func (suite *NodePublicTestSuite) TestOS() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.OSInfoResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get os")
@@ -589,11 +589,11 @@ func (suite *NodePublicTestSuite) TestOS() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.OSInfoResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.OSInfoResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -618,10 +618,10 @@ func (suite *NodePublicTestSuite) TestOS() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.OS(suite.ctx, tc.target)
@@ -636,7 +636,7 @@ func (suite *NodePublicTestSuite) TestUptime() {
handler http.HandlerFunc
serverURL string
target string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.UptimeResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.UptimeResult]], error)
}{
{
name: "when requesting uptime returns results",
@@ -648,7 +648,7 @@ func (suite *NodePublicTestSuite) TestUptime() {
[]byte(`{"results":[{"hostname":"uptime-host","uptime":"2d3h"}]}`),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.UptimeResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -664,11 +664,11 @@ func (suite *NodePublicTestSuite) TestUptime() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.UptimeResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -677,7 +677,7 @@ func (suite *NodePublicTestSuite) TestUptime() {
name: "when client HTTP call fails returns error",
target: "_any",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.UptimeResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get uptime")
@@ -689,11 +689,11 @@ func (suite *NodePublicTestSuite) TestUptime() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.UptimeResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.UptimeResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -718,10 +718,10 @@ func (suite *NodePublicTestSuite) TestUptime() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Uptime(suite.ctx, tc.target)
@@ -737,7 +737,7 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
serverURL string
target string
interfaceName string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.DNSConfig]], error)
+ validateFunc func(*client.Response[client.Collection[client.DNSConfig]], error)
}{
{
name: "when requesting DNS returns results",
@@ -750,7 +750,7 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
[]byte(`{"results":[{"hostname":"dns-host","servers":["8.8.8.8"]}]}`),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSConfig]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -767,11 +767,11 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSConfig]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -781,7 +781,7 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
target: "_any",
interfaceName: "eth0",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSConfig]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "get dns")
@@ -794,11 +794,11 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSConfig]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSConfig]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -823,10 +823,10 @@ func (suite *NodePublicTestSuite) TestGetDNS() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.GetDNS(suite.ctx, tc.target, tc.interfaceName)
@@ -844,7 +844,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
iface string
servers []string
searchDomains []string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.DNSUpdateResult]], error)
}{
{
name: "when servers only provided sets servers",
@@ -861,7 +861,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -885,7 +885,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
@@ -905,7 +905,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
@@ -925,7 +925,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
@@ -940,11 +940,11 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -955,7 +955,7 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
iface: "eth0",
servers: []string{"8.8.8.8"},
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "update dns")
@@ -969,11 +969,11 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.DNSUpdateResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.DNSUpdateResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusAccepted, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -998,10 +998,10 @@ func (suite *NodePublicTestSuite) TestUpdateDNS() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.UpdateDNS(
@@ -1023,7 +1023,7 @@ func (suite *NodePublicTestSuite) TestPing() {
serverURL string
target string
address string
- validateFunc func(*osapi.Response[osapi.Collection[osapi.PingResult]], error)
+ validateFunc func(*client.Response[client.Collection[client.PingResult]], error)
}{
{
name: "when pinging address returns results",
@@ -1038,7 +1038,7 @@ func (suite *NodePublicTestSuite) TestPing() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.PingResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -1055,11 +1055,11 @@ func (suite *NodePublicTestSuite) TestPing() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.PingResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -1069,7 +1069,7 @@ func (suite *NodePublicTestSuite) TestPing() {
target: "_any",
address: "8.8.8.8",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.PingResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "ping")
@@ -1082,11 +1082,11 @@ func (suite *NodePublicTestSuite) TestPing() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.PingResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.PingResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -1111,10 +1111,10 @@ func (suite *NodePublicTestSuite) TestPing() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Ping(suite.ctx, tc.target, tc.address)
@@ -1128,12 +1128,12 @@ func (suite *NodePublicTestSuite) TestExec() {
name string
handler http.HandlerFunc
serverURL string
- req osapi.ExecRequest
- validateFunc func(*osapi.Response[osapi.Collection[osapi.CommandResult]], error)
+ req client.ExecRequest
+ validateFunc func(*client.Response[client.Collection[client.CommandResult]], error)
}{
{
name: "when basic command returns results",
- req: osapi.ExecRequest{
+ req: client.ExecRequest{
Command: "whoami",
Target: "_any",
},
@@ -1146,7 +1146,7 @@ func (suite *NodePublicTestSuite) TestExec() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -1157,7 +1157,7 @@ func (suite *NodePublicTestSuite) TestExec() {
},
{
name: "when all options provided returns results",
- req: osapi.ExecRequest{
+ req: client.ExecRequest{
Command: "ls",
Args: []string{"-la", "/tmp"},
Cwd: "/tmp",
@@ -1173,14 +1173,14 @@ func (suite *NodePublicTestSuite) TestExec() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
},
{
name: "when server returns 400 returns ValidationError",
- req: osapi.ExecRequest{
+ req: client.ExecRequest{
Target: "_any",
},
handler: func(w http.ResponseWriter, _ *http.Request) {
@@ -1188,11 +1188,11 @@ func (suite *NodePublicTestSuite) TestExec() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"command is required"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -1200,11 +1200,11 @@ func (suite *NodePublicTestSuite) TestExec() {
{
name: "when client HTTP call fails returns error",
serverURL: "http://127.0.0.1:0",
- req: osapi.ExecRequest{
+ req: client.ExecRequest{
Command: "whoami",
Target: "_any",
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "exec command")
@@ -1212,18 +1212,18 @@ func (suite *NodePublicTestSuite) TestExec() {
},
{
name: "when server returns 202 with no JSON body returns UnexpectedStatusError",
- req: osapi.ExecRequest{
+ req: client.ExecRequest{
Command: "whoami",
Target: "_any",
},
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusAccepted, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -1248,10 +1248,10 @@ func (suite *NodePublicTestSuite) TestExec() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Exec(suite.ctx, tc.req)
@@ -1265,12 +1265,12 @@ func (suite *NodePublicTestSuite) TestShell() {
name string
handler http.HandlerFunc
serverURL string
- req osapi.ShellRequest
- validateFunc func(*osapi.Response[osapi.Collection[osapi.CommandResult]], error)
+ req client.ShellRequest
+ validateFunc func(*client.Response[client.Collection[client.CommandResult]], error)
}{
{
name: "when basic command returns results",
- req: osapi.ShellRequest{
+ req: client.ShellRequest{
Command: "uname -a",
Target: "_any",
},
@@ -1283,7 +1283,7 @@ func (suite *NodePublicTestSuite) TestShell() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Len(resp.Data.Results, 1)
@@ -1292,7 +1292,7 @@ func (suite *NodePublicTestSuite) TestShell() {
},
{
name: "when cwd and timeout provided returns results",
- req: osapi.ShellRequest{
+ req: client.ShellRequest{
Command: "ls -la",
Cwd: "/var/log",
Timeout: 15,
@@ -1307,14 +1307,14 @@ func (suite *NodePublicTestSuite) TestShell() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
},
{
name: "when server returns 400 returns ValidationError",
- req: osapi.ShellRequest{
+ req: client.ShellRequest{
Target: "_any",
},
handler: func(w http.ResponseWriter, _ *http.Request) {
@@ -1322,11 +1322,11 @@ func (suite *NodePublicTestSuite) TestShell() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"command is required"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -1334,11 +1334,11 @@ func (suite *NodePublicTestSuite) TestShell() {
{
name: "when client HTTP call fails returns error",
serverURL: "http://127.0.0.1:0",
- req: osapi.ShellRequest{
+ req: client.ShellRequest{
Command: "uname -a",
Target: "_any",
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "shell command")
@@ -1346,18 +1346,18 @@ func (suite *NodePublicTestSuite) TestShell() {
},
{
name: "when server returns 202 with no JSON body returns UnexpectedStatusError",
- req: osapi.ShellRequest{
+ req: client.ShellRequest{
Command: "uname -a",
Target: "_any",
},
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
},
- validateFunc: func(resp *osapi.Response[osapi.Collection[osapi.CommandResult]], err error) {
+ validateFunc: func(resp *client.Response[client.Collection[client.CommandResult]], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusAccepted, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -1382,10 +1382,10 @@ func (suite *NodePublicTestSuite) TestShell() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.Shell(suite.ctx, tc.req)
@@ -1399,12 +1399,12 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
name string
handler http.HandlerFunc
serverURL string
- req osapi.FileDeployOpts
- validateFunc func(*osapi.Response[osapi.FileDeployResult], error)
+ req client.FileDeployOpts
+ validateFunc func(*client.Response[client.FileDeployResult], error)
}{
{
name: "when deploying file returns result",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
@@ -1419,7 +1419,7 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("job-123", resp.Data.JobID)
@@ -1429,7 +1429,7 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
},
{
name: "when all options provided returns results",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
ObjectName: "app.conf.tmpl",
Path: "/etc/app/app.conf",
ContentType: "template",
@@ -1448,14 +1448,14 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.NoError(err)
suite.NotNil(resp)
},
},
{
name: "when server returns 400 returns ValidationError",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
Target: "_any",
},
handler: func(w http.ResponseWriter, _ *http.Request) {
@@ -1463,18 +1463,18 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"object_name is required"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
},
{
name: "when server returns 403 returns AuthError",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
@@ -1485,11 +1485,11 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -1497,13 +1497,13 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
{
name: "when client HTTP call fails returns error",
serverURL: "http://127.0.0.1:0",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
Target: "_any",
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "file deploy")
@@ -1511,7 +1511,7 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
},
{
name: "when server returns 202 with no JSON body returns UnexpectedStatusError",
- req: osapi.FileDeployOpts{
+ req: client.FileDeployOpts{
ObjectName: "nginx.conf",
Path: "/etc/nginx/nginx.conf",
ContentType: "raw",
@@ -1520,11 +1520,11 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusAccepted)
},
- validateFunc: func(resp *osapi.Response[osapi.FileDeployResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileDeployResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusAccepted, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -1549,10 +1549,10 @@ func (suite *NodePublicTestSuite) TestFileDeploy() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.FileDeploy(suite.ctx, tc.req)
@@ -1568,7 +1568,7 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
serverURL string
target string
path string
- validateFunc func(*osapi.Response[osapi.FileStatusResult], error)
+ validateFunc func(*client.Response[client.FileStatusResult], error)
}{
{
name: "when checking file status returns result",
@@ -1583,7 +1583,7 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
),
)
},
- validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileStatusResult], err error) {
suite.NoError(err)
suite.NotNil(resp)
suite.Equal("job-789", resp.Data.JobID)
@@ -1602,11 +1602,11 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"path is required"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileStatusResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.ValidationError
+ var target *client.ValidationError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusBadRequest, target.StatusCode)
},
@@ -1620,11 +1620,11 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
},
- validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileStatusResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.AuthError
+ var target *client.AuthError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusForbidden, target.StatusCode)
},
@@ -1634,7 +1634,7 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
target: "_any",
path: "/etc/nginx/nginx.conf",
serverURL: "http://127.0.0.1:0",
- validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileStatusResult], err error) {
suite.Error(err)
suite.Nil(resp)
suite.Contains(err.Error(), "file status")
@@ -1647,11 +1647,11 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
- validateFunc: func(resp *osapi.Response[osapi.FileStatusResult], err error) {
+ validateFunc: func(resp *client.Response[client.FileStatusResult], err error) {
suite.Error(err)
suite.Nil(resp)
- var target *osapi.UnexpectedStatusError
+ var target *client.UnexpectedStatusError
suite.True(errors.As(err, &target))
suite.Equal(http.StatusOK, target.StatusCode)
suite.Equal("nil response body", target.Message)
@@ -1676,10 +1676,10 @@ func (suite *NodePublicTestSuite) TestFileStatus() {
}
defer cleanup()
- sut := osapi.New(
+ sut := client.New(
serverURL,
"test-token",
- osapi.WithLogger(slog.Default()),
+ client.WithLogger(slog.Default()),
)
resp, err := sut.Node.FileStatus(suite.ctx, tc.target, tc.path)
diff --git a/pkg/sdk/osapi/node_types.go b/pkg/sdk/client/node_types.go
similarity index 99%
rename from pkg/sdk/osapi/node_types.go
rename to pkg/sdk/client/node_types.go
index b54f6390..3848df86 100644
--- a/pkg/sdk/osapi/node_types.go
+++ b/pkg/sdk/client/node_types.go
@@ -18,12 +18,12 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
openapi_types "github.com/oapi-codegen/runtime/types"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// Collection is a generic wrapper for collection responses from node queries.
diff --git a/pkg/sdk/osapi/node_types_test.go b/pkg/sdk/client/node_types_test.go
similarity index 99%
rename from pkg/sdk/osapi/node_types_test.go
rename to pkg/sdk/client/node_types_test.go
index b884bf49..e3d2b6e1 100644
--- a/pkg/sdk/osapi/node_types_test.go
+++ b/pkg/sdk/client/node_types_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"testing"
@@ -26,7 +26,7 @@ import (
openapi_types "github.com/oapi-codegen/runtime/types"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type NodeTypesTestSuite struct {
diff --git a/pkg/sdk/osapi/osapi.go b/pkg/sdk/client/osapi.go
similarity index 93%
rename from pkg/sdk/osapi/osapi.go
rename to pkg/sdk/client/osapi.go
index 54136f86..c421c7e9 100644
--- a/pkg/sdk/osapi/osapi.go
+++ b/pkg/sdk/client/osapi.go
@@ -18,28 +18,28 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-// Package osapi provides a Go SDK for the OSAPI REST API.
+// Package client provides a Go SDK for the OSAPI REST API.
//
// Create a client with New() and use the domain-specific services
// to interact with the API:
//
-// client := osapi.New("http://localhost:8080", "your-jwt-token")
+// client := client.New("http://localhost:8080", "your-jwt-token")
//
// // Get hostname
// resp, err := client.Node.Hostname(ctx, "_any")
//
// // Execute a command
-// resp, err := client.Node.Exec(ctx, osapi.ExecRequest{
+// resp, err := client.Node.Exec(ctx, client.ExecRequest{
// Command: "uptime",
// Target: "_all",
// })
-package osapi
+package client
import (
"log/slog"
"net/http"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// Client is the top-level OSAPI SDK client. Use New() to create one.
diff --git a/pkg/sdk/osapi/osapi_public_test.go b/pkg/sdk/client/osapi_public_test.go
similarity index 83%
rename from pkg/sdk/osapi/osapi_public_test.go
rename to pkg/sdk/client/osapi_public_test.go
index f572c613..cb239c5c 100644
--- a/pkg/sdk/osapi/osapi_public_test.go
+++ b/pkg/sdk/client/osapi_public_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"log/slog"
@@ -28,7 +28,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type ClientPublicTestSuite struct {
@@ -54,15 +54,15 @@ func (suite *ClientPublicTestSuite) TearDownTest() {
func (suite *ClientPublicTestSuite) TestNew() {
tests := []struct {
name string
- opts func() []osapi.Option
- validateFunc func(*osapi.Client)
+ opts func() []client.Option
+ validateFunc func(*client.Client)
}{
{
name: "when creating client returns all services",
- opts: func() []osapi.Option {
+ opts: func() []client.Option {
return nil
},
- validateFunc: func(c *osapi.Client) {
+ validateFunc: func(c *client.Client) {
suite.NotNil(c)
suite.NotNil(c.Node)
suite.NotNil(c.Job)
@@ -74,13 +74,13 @@ func (suite *ClientPublicTestSuite) TestNew() {
},
{
name: "when custom transport provided creates client",
- opts: func() []osapi.Option {
- return []osapi.Option{
- osapi.WithHTTPTransport(&http.Transport{}),
- osapi.WithLogger(slog.Default()),
+ opts: func() []client.Option {
+ return []client.Option{
+ client.WithHTTPTransport(&http.Transport{}),
+ client.WithLogger(slog.Default()),
}
},
- validateFunc: func(c *osapi.Client) {
+ validateFunc: func(c *client.Client) {
suite.NotNil(c)
},
},
@@ -88,7 +88,7 @@ func (suite *ClientPublicTestSuite) TestNew() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- c := osapi.New(suite.server.URL, "test-token", tc.opts()...)
+ c := client.New(suite.server.URL, "test-token", tc.opts()...)
tc.validateFunc(c)
})
}
diff --git a/pkg/sdk/osapi/response.go b/pkg/sdk/client/response.go
similarity index 97%
rename from pkg/sdk/osapi/response.go
rename to pkg/sdk/client/response.go
index b02f1423..beeef194 100644
--- a/pkg/sdk/osapi/response.go
+++ b/pkg/sdk/client/response.go
@@ -18,12 +18,12 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"fmt"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
// Response wraps a domain type with raw JSON for CLI --json mode.
diff --git a/pkg/sdk/osapi/response_public_test.go b/pkg/sdk/client/response_public_test.go
similarity index 83%
rename from pkg/sdk/osapi/response_public_test.go
rename to pkg/sdk/client/response_public_test.go
index b5ac9a64..06d25872 100644
--- a/pkg/sdk/osapi/response_public_test.go
+++ b/pkg/sdk/client/response_public_test.go
@@ -18,14 +18,14 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi_test
+package client_test
import (
"testing"
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi"
+ "github.com/retr0h/osapi/pkg/sdk/client"
)
type ResponsePublicTestSuite struct {
@@ -36,12 +36,12 @@ func (suite *ResponsePublicTestSuite) TestRawJSON() {
tests := []struct {
name string
rawJSON []byte
- validateFunc func(*osapi.Response[string])
+ validateFunc func(*client.Response[string])
}{
{
name: "when RawJSON returns the raw bytes",
rawJSON: []byte(`{"hostname":"web-01"}`),
- validateFunc: func(resp *osapi.Response[string]) {
+ validateFunc: func(resp *client.Response[string]) {
suite.Equal(
[]byte(`{"hostname":"web-01"}`),
resp.RawJSON(),
@@ -51,7 +51,7 @@ func (suite *ResponsePublicTestSuite) TestRawJSON() {
{
name: "when RawJSON returns nil for empty response",
rawJSON: nil,
- validateFunc: func(resp *osapi.Response[string]) {
+ validateFunc: func(resp *client.Response[string]) {
suite.Nil(resp.RawJSON())
},
},
@@ -59,7 +59,7 @@ func (suite *ResponsePublicTestSuite) TestRawJSON() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- resp := osapi.NewResponse("test", tc.rawJSON)
+ resp := client.NewResponse("test", tc.rawJSON)
tc.validateFunc(resp)
})
}
@@ -70,13 +70,13 @@ func (suite *ResponsePublicTestSuite) TestData() {
name string
data string
rawJSON []byte
- validateFunc func(*osapi.Response[string])
+ validateFunc func(*client.Response[string])
}{
{
name: "when Data contains the domain type",
data: "web-01",
rawJSON: []byte(`{"hostname":"web-01"}`),
- validateFunc: func(resp *osapi.Response[string]) {
+ validateFunc: func(resp *client.Response[string]) {
suite.Equal("web-01", resp.Data)
},
},
@@ -84,7 +84,7 @@ func (suite *ResponsePublicTestSuite) TestData() {
name: "when Data contains an empty string",
data: "",
rawJSON: []byte(`{}`),
- validateFunc: func(resp *osapi.Response[string]) {
+ validateFunc: func(resp *client.Response[string]) {
suite.Empty(resp.Data)
},
},
@@ -92,7 +92,7 @@ func (suite *ResponsePublicTestSuite) TestData() {
for _, tc := range tests {
suite.Run(tc.name, func() {
- resp := osapi.NewResponse(tc.data, tc.rawJSON)
+ resp := client.NewResponse(tc.data, tc.rawJSON)
tc.validateFunc(resp)
})
}
diff --git a/pkg/sdk/osapi/response_test.go b/pkg/sdk/client/response_test.go
similarity index 98%
rename from pkg/sdk/osapi/response_test.go
rename to pkg/sdk/client/response_test.go
index 182288be..c5482b49 100644
--- a/pkg/sdk/osapi/response_test.go
+++ b/pkg/sdk/client/response_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"errors"
@@ -26,7 +26,7 @@ import (
"github.com/stretchr/testify/suite"
- "github.com/retr0h/osapi/pkg/sdk/osapi/gen"
+ "github.com/retr0h/osapi/pkg/sdk/client/gen"
)
type ResponseTestSuite struct {
diff --git a/pkg/sdk/osapi/transport.go b/pkg/sdk/client/transport.go
similarity index 99%
rename from pkg/sdk/osapi/transport.go
rename to pkg/sdk/client/transport.go
index 323a7f15..d9f1d820 100644
--- a/pkg/sdk/osapi/transport.go
+++ b/pkg/sdk/client/transport.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"log/slog"
diff --git a/pkg/sdk/osapi/transport_test.go b/pkg/sdk/client/transport_test.go
similarity index 99%
rename from pkg/sdk/osapi/transport_test.go
rename to pkg/sdk/client/transport_test.go
index c5e2cfb9..fa18f312 100644
--- a/pkg/sdk/osapi/transport_test.go
+++ b/pkg/sdk/client/transport_test.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
import (
"fmt"
diff --git a/pkg/sdk/osapi/types.go b/pkg/sdk/client/types.go
similarity index 98%
rename from pkg/sdk/osapi/types.go
rename to pkg/sdk/client/types.go
index eae7db17..8fa2cf52 100644
--- a/pkg/sdk/osapi/types.go
+++ b/pkg/sdk/client/types.go
@@ -18,7 +18,7 @@
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
// DEALINGS IN THE SOFTWARE.
-package osapi
+package client
// TimelineEvent represents a lifecycle event. Used by both job
// timelines and agent state transition history.
diff --git a/pkg/sdk/orchestrator/options.go b/pkg/sdk/orchestrator/options.go
new file mode 100644
index 00000000..0ab7ea8c
--- /dev/null
+++ b/pkg/sdk/orchestrator/options.go
@@ -0,0 +1,79 @@
+package orchestrator
+
+import "fmt"
+
+// ErrorStrategy defines how the runner handles task failures.
+type ErrorStrategy struct {
+ kind string
+ retryCount int
+}
+
+// StopAll cancels all remaining tasks on first failure.
+var StopAll = ErrorStrategy{kind: "stop_all"}
+
+// Continue skips dependents of the failed task but continues
+// independent tasks.
+var Continue = ErrorStrategy{kind: "continue"}
+
+// Retry returns a strategy that retries a failed task n times
+// before failing.
+func Retry(
+ n int,
+) ErrorStrategy {
+ return ErrorStrategy{kind: "retry", retryCount: n}
+}
+
+// String returns a human-readable representation of the strategy.
+func (e ErrorStrategy) String() string {
+ if e.kind == "retry" {
+ return fmt.Sprintf("retry(%d)", e.retryCount)
+ }
+
+ return e.kind
+}
+
+// RetryCount returns the number of retries for this strategy.
+func (e ErrorStrategy) RetryCount() int {
+ return e.retryCount
+}
+
+// Hooks provides consumer-controlled callbacks for plan execution
+// events. All fields are optional — nil callbacks are skipped.
+// The SDK performs no logging; hooks are the only output mechanism.
+type Hooks struct {
+ BeforePlan func(summary PlanSummary)
+ AfterPlan func(report *Report)
+ BeforeLevel func(level int, tasks []*Task, parallel bool)
+ AfterLevel func(level int, results []TaskResult)
+ BeforeTask func(task *Task)
+ AfterTask func(task *Task, result TaskResult)
+ OnRetry func(task *Task, attempt int, err error)
+ OnSkip func(task *Task, reason string)
+}
+
+// PlanConfig holds plan-level configuration.
+type PlanConfig struct {
+ OnErrorStrategy ErrorStrategy
+ Hooks *Hooks
+}
+
+// PlanOption is a functional option for NewPlan.
+type PlanOption func(*PlanConfig)
+
+// OnError returns a PlanOption that sets the default error strategy.
+func OnError(
+ strategy ErrorStrategy,
+) PlanOption {
+ return func(cfg *PlanConfig) {
+ cfg.OnErrorStrategy = strategy
+ }
+}
+
+// WithHooks attaches lifecycle callbacks to plan execution.
+func WithHooks(
+ hooks Hooks,
+) PlanOption {
+ return func(cfg *PlanConfig) {
+ cfg.Hooks = &hooks
+ }
+}
diff --git a/pkg/sdk/orchestrator/options_public_test.go b/pkg/sdk/orchestrator/options_public_test.go
new file mode 100644
index 00000000..19c76ebc
--- /dev/null
+++ b/pkg/sdk/orchestrator/options_public_test.go
@@ -0,0 +1,137 @@
+package orchestrator_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+type OptionsPublicTestSuite struct {
+ suite.Suite
+}
+
+func TestOptionsPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(OptionsPublicTestSuite))
+}
+
+func (s *OptionsPublicTestSuite) TestErrorStrategy() {
+ tests := []struct {
+ name string
+ strategy orchestrator.ErrorStrategy
+ wantStr string
+ }{
+ {
+ name: "stop all",
+ strategy: orchestrator.StopAll,
+ wantStr: "stop_all",
+ },
+ {
+ name: "continue",
+ strategy: orchestrator.Continue,
+ wantStr: "continue",
+ },
+ {
+ name: "retry",
+ strategy: orchestrator.Retry(3),
+ wantStr: "retry(3)",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ s.Equal(tt.wantStr, tt.strategy.String())
+ })
+ }
+}
+
+func (s *OptionsPublicTestSuite) TestRetryCount() {
+ tests := []struct {
+ name string
+ strategy orchestrator.ErrorStrategy
+ want int
+ }{
+ {
+ name: "stop all has zero retries",
+ strategy: orchestrator.StopAll,
+ want: 0,
+ },
+ {
+ name: "continue has zero retries",
+ strategy: orchestrator.Continue,
+ want: 0,
+ },
+ {
+ name: "retry has n retries",
+ strategy: orchestrator.Retry(5),
+ want: 5,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ s.Equal(tt.want, tt.strategy.RetryCount())
+ })
+ }
+}
+
+func (s *OptionsPublicTestSuite) TestWithHooks() {
+ called := false
+ hooks := orchestrator.Hooks{
+ BeforeTask: func(_ *orchestrator.Task) {
+ called = true
+ },
+ }
+
+ cfg := orchestrator.PlanConfig{}
+ opt := orchestrator.WithHooks(hooks)
+ opt(&cfg)
+
+ s.NotNil(cfg.Hooks)
+ s.NotNil(cfg.Hooks.BeforeTask)
+
+ // Create a task to pass to the callback.
+ t := orchestrator.NewTask(
+ "test",
+ &orchestrator.Op{Operation: "node.hostname.get", Target: "_any"},
+ )
+ cfg.Hooks.BeforeTask(t)
+ s.True(called)
+}
+
+func (s *OptionsPublicTestSuite) TestHooksDefaults() {
+ h := orchestrator.Hooks{}
+
+ // Nil callbacks should be safe — no panic.
+ s.Nil(h.BeforePlan)
+ s.Nil(h.AfterPlan)
+ s.Nil(h.BeforeLevel)
+ s.Nil(h.AfterLevel)
+ s.Nil(h.BeforeTask)
+ s.Nil(h.AfterTask)
+ s.Nil(h.OnRetry)
+ s.Nil(h.OnSkip)
+}
+
+func (s *OptionsPublicTestSuite) TestPlanOption() {
+ tests := []struct {
+ name string
+ option orchestrator.PlanOption
+ wantOnError orchestrator.ErrorStrategy
+ }{
+ {
+ name: "on error sets strategy",
+ option: orchestrator.OnError(orchestrator.Continue),
+ wantOnError: orchestrator.Continue,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ cfg := &orchestrator.PlanConfig{}
+ tt.option(cfg)
+ s.Equal(tt.wantOnError.String(), cfg.OnErrorStrategy.String())
+ })
+ }
+}
diff --git a/pkg/sdk/orchestrator/plan.go b/pkg/sdk/orchestrator/plan.go
new file mode 100644
index 00000000..291b2efa
--- /dev/null
+++ b/pkg/sdk/orchestrator/plan.go
@@ -0,0 +1,226 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// Plan is a DAG of tasks with dependency edges.
+type Plan struct {
+ client *osapiclient.Client
+ tasks []*Task
+ config PlanConfig
+}
+
+// NewPlan creates a new plan bound to an OSAPI client.
+func NewPlan(
+ client *osapiclient.Client,
+ opts ...PlanOption,
+) *Plan {
+ cfg := PlanConfig{
+ OnErrorStrategy: StopAll,
+ }
+
+ for _, opt := range opts {
+ opt(&cfg)
+ }
+
+ return &Plan{
+ client: client,
+ config: cfg,
+ }
+}
+
+// Client returns the OSAPI client bound to this plan.
+func (p *Plan) Client() *osapiclient.Client {
+ return p.client
+}
+
+// Config returns the plan configuration.
+func (p *Plan) Config() PlanConfig {
+ return p.config
+}
+
+// Task creates a declarative task, adds it to the plan, and returns it.
+func (p *Plan) Task(
+ name string,
+ op *Op,
+) *Task {
+ t := NewTask(name, op)
+ p.tasks = append(p.tasks, t)
+
+ return t
+}
+
+// TaskFunc creates a functional task, adds it to the plan, and
+// returns it.
+func (p *Plan) TaskFunc(
+ name string,
+ fn TaskFn,
+) *Task {
+ t := NewTaskFunc(name, fn)
+ p.tasks = append(p.tasks, t)
+
+ return t
+}
+
+// TaskFuncWithResults creates a functional task that receives
+// completed results from prior tasks, adds it to the plan, and
+// returns it.
+func (p *Plan) TaskFuncWithResults(
+ name string,
+ fn TaskFnWithResults,
+) *Task {
+ t := NewTaskFuncWithResults(name, fn)
+ p.tasks = append(p.tasks, t)
+
+ return t
+}
+
+// Tasks returns all tasks in the plan.
+func (p *Plan) Tasks() []*Task {
+ return p.tasks
+}
+
+// Explain returns a human-readable representation of the execution
+// plan showing levels, parallelism, dependencies, and guards.
+func (p *Plan) Explain() string {
+ levels, err := p.Levels()
+ if err != nil {
+ return fmt.Sprintf("invalid plan: %s", err)
+ }
+
+ var b strings.Builder
+
+ fmt.Fprintf(&b, "Plan: %d tasks, %d levels\n", len(p.tasks), len(levels))
+
+ for i, level := range levels {
+ if len(level) > 1 {
+ fmt.Fprintf(&b, "\nLevel %d (parallel):\n", i)
+ } else {
+ fmt.Fprintf(&b, "\nLevel %d:\n", i)
+ }
+
+ for _, t := range level {
+ kind := "op"
+ if t.IsFunc() {
+ kind = "fn"
+ }
+
+ fmt.Fprintf(&b, " %s [%s]", t.name, kind)
+
+ if len(t.deps) > 0 {
+ names := make([]string, len(t.deps))
+ for j, dep := range t.deps {
+ names[j] = dep.name
+ }
+
+ fmt.Fprintf(&b, " <- %s", strings.Join(names, ", "))
+ }
+
+ var flags []string
+ if t.requiresChange {
+ flags = append(flags, "only-if-changed")
+ }
+
+ if t.guard != nil {
+ flags = append(flags, "when")
+ }
+
+ if len(flags) > 0 {
+ fmt.Fprintf(&b, " (%s)", strings.Join(flags, ", "))
+ }
+
+ fmt.Fprintln(&b)
+ }
+ }
+
+ return b.String()
+}
+
+// Levels returns the levelized DAG -- tasks grouped into execution
+// levels where all tasks in a level can run concurrently.
+// Returns an error if the plan fails validation.
+func (p *Plan) Levels() ([][]*Task, error) {
+ if err := p.Validate(); err != nil {
+ return nil, err
+ }
+
+ return levelize(p.tasks), nil
+}
+
+// Validate checks the plan for errors: duplicate names and cycles.
+func (p *Plan) Validate() error {
+ names := make(map[string]bool, len(p.tasks))
+
+ for _, t := range p.tasks {
+ if names[t.name] {
+ return fmt.Errorf("duplicate task name: %q", t.name)
+ }
+
+ names[t.name] = true
+ }
+
+ return p.detectCycle()
+}
+
+// Run validates the plan, resolves the DAG, and executes tasks.
+func (p *Plan) Run(
+ ctx context.Context,
+) (*Report, error) {
+ if err := p.Validate(); err != nil {
+ return nil, fmt.Errorf("plan validation: %w", err)
+ }
+
+ runner := newRunner(p)
+
+ return runner.run(ctx)
+}
+
+// detectCycle uses DFS to find cycles in the dependency graph.
+func (p *Plan) detectCycle() error {
+ const (
+ white = 0 // unvisited
+ gray = 1 // in progress
+ black = 2 // done
+ )
+
+ color := make(map[string]int, len(p.tasks))
+
+ var visit func(t *Task) error
+ visit = func(t *Task) error {
+ color[t.name] = gray
+
+ for _, dep := range t.deps {
+ switch color[dep.name] {
+ case gray:
+ return fmt.Errorf(
+ "cycle detected: %q depends on %q",
+ t.name,
+ dep.name,
+ )
+ case white:
+ if err := visit(dep); err != nil {
+ return err
+ }
+ }
+ }
+
+ color[t.name] = black
+
+ return nil
+ }
+
+ for _, t := range p.tasks {
+ if color[t.name] == white {
+ if err := visit(t); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/sdk/orchestrator/plan_public_test.go b/pkg/sdk/orchestrator/plan_public_test.go
new file mode 100644
index 00000000..43562617
--- /dev/null
+++ b/pkg/sdk/orchestrator/plan_public_test.go
@@ -0,0 +1,1343 @@
+package orchestrator_test
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+type PlanPublicTestSuite struct {
+ suite.Suite
+}
+
+func TestPlanPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(PlanPublicTestSuite))
+}
+
+// pollResponse describes one GET /job/{id} response from opServer.
+type pollResponse struct {
+ status string // "pending", "completed", "failed", or "" (omit field)
+ result any // nil, map[string]any, string
+ err string // error message for failed jobs
+ code int // HTTP status code (0 = 200)
+}
+
+// opServer creates an httptest.Server that handles job create + poll.
+// createCode is the HTTP status for POST /job.
+// pollResponses is a sequence of responses returned on successive GET
+// /job/{id} calls.
+func opServer(
+ s *PlanPublicTestSuite,
+ createCode int,
+ createErrMsg string,
+ pollResponses []pollResponse,
+) *httptest.Server {
+ s.T().Helper()
+
+ var pollIdx atomic.Int32
+ jobID := "00000000-0000-0000-0000-000000000001"
+
+ return httptest.NewServer(http.HandlerFunc(func(
+ w http.ResponseWriter,
+ r *http.Request,
+ ) {
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/job":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(createCode)
+ if createCode == http.StatusCreated {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "job_id": jobID,
+ "status": "pending",
+ })
+ } else {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "error": createErrMsg,
+ })
+ }
+ case r.Method == "GET" && r.URL.Path == "/job/"+jobID:
+ idx := int(pollIdx.Add(1)) - 1
+ if idx >= len(pollResponses) {
+ idx = len(pollResponses) - 1
+ }
+
+ pr := pollResponses[idx]
+
+ code := pr.code
+ if code == 0 {
+ code = http.StatusOK
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+
+ resp := map[string]any{"id": jobID}
+ if pr.status != "" {
+ resp["status"] = pr.status
+ }
+ if pr.result != nil {
+ resp["result"] = pr.result
+ }
+ if pr.err != "" {
+ resp["error"] = pr.err
+ }
+
+ _ = json.NewEncoder(w).Encode(resp)
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+}
+
+// withShortPoll sets DefaultPollInterval to 10ms for the test duration.
+func withShortPoll() func() {
+ orig := orchestrator.DefaultPollInterval
+ orchestrator.DefaultPollInterval = 10 * time.Millisecond
+
+ return func() { orchestrator.DefaultPollInterval = orig }
+}
+
+// taskFunc creates a TaskFn with the given changed value and optional
+// side effect.
+func taskFunc(
+ changed bool,
+ sideEffect func(),
+) orchestrator.TaskFn {
+ return func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ if sideEffect != nil {
+ sideEffect()
+ }
+
+ return &orchestrator.Result{Changed: changed}, nil
+ }
+}
+
+// failFunc creates a TaskFn that always returns the given error.
+func failFunc(msg string) orchestrator.TaskFn {
+ return func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ return nil, fmt.Errorf("%s", msg)
+ }
+}
+
+// statusMap builds a name→status map from a report for easy assertions.
+func statusMap(report *orchestrator.Report) map[string]orchestrator.Status {
+ m := make(map[string]orchestrator.Status, len(report.Tasks))
+ for _, r := range report.Tasks {
+ m[r.Name] = r.Status
+ }
+
+ return m
+}
+
+// filterPrefix returns only strings that start with prefix.
+func filterPrefix(
+ ss []string,
+ prefix string,
+) []string {
+ var out []string
+ for _, s := range ss {
+ if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
+ out = append(out, s)
+ }
+ }
+
+ return out
+}
+
+func (s *PlanPublicTestSuite) TestRun() {
+ s.Run("linear chain executes in order", func() {
+ var order []string
+ plan := orchestrator.NewPlan(nil)
+
+ mk := func(name string, changed bool) *orchestrator.Task {
+ n := name
+
+ return plan.TaskFunc(n, taskFunc(changed, func() {
+ order = append(order, n)
+ }))
+ }
+
+ a := mk("a", true)
+ b := mk("b", true)
+ c := mk("c", false)
+ b.DependsOn(a)
+ c.DependsOn(b)
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ s.Equal([]string{"a", "b", "c"}, order)
+ s.Len(report.Tasks, 3)
+ s.Contains(report.Summary(), "2 changed")
+ s.Contains(report.Summary(), "1 unchanged")
+ })
+
+ s.Run("parallel tasks run concurrently", func() {
+ var concurrentMax atomic.Int32
+ var concurrent atomic.Int32
+
+ plan := orchestrator.NewPlan(nil)
+
+ for _, name := range []string{"a", "b", "c"} {
+ plan.TaskFunc(name, func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ cur := concurrent.Add(1)
+ for {
+ prev := concurrentMax.Load()
+ if cur > prev {
+ if concurrentMax.CompareAndSwap(prev, cur) {
+ break
+ }
+ } else {
+ break
+ }
+ }
+ concurrent.Add(-1)
+
+ return &orchestrator.Result{Changed: false}, nil
+ })
+ }
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ s.Len(report.Tasks, 3)
+ s.GreaterOrEqual(int(concurrentMax.Load()), 1)
+ })
+
+ s.Run("cycle detection returns error", func() {
+ plan := orchestrator.NewPlan(nil)
+ a := plan.Task("a", &orchestrator.Op{Operation: "noop"})
+ b := plan.Task("b", &orchestrator.Op{Operation: "noop"})
+ a.DependsOn(b)
+ b.DependsOn(a)
+
+ _, err := plan.Run(context.Background())
+ s.Error(err)
+ s.Contains(err.Error(), "cycle")
+ })
+}
+
+func (s *PlanPublicTestSuite) TestRunOnlyIfChanged() {
+ tests := []struct {
+ name string
+ depChanged bool
+ validateFunc func(report *orchestrator.Report, ran bool)
+ }{
+ {
+ name: "skips when no dependency changed",
+ depChanged: false,
+ validateFunc: func(report *orchestrator.Report, ran bool) {
+ s.False(ran)
+ s.Contains(report.Summary(), "skipped")
+ },
+ },
+ {
+ name: "runs when dependency changed",
+ depChanged: true,
+ validateFunc: func(report *orchestrator.Report, ran bool) {
+ s.True(ran)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[1].Status)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(nil)
+ ran := false
+
+ dep := plan.TaskFunc("dep", taskFunc(tt.depChanged, nil))
+ conditional := plan.TaskFunc("conditional", taskFunc(false, func() {
+ ran = true
+ }))
+ conditional.DependsOn(dep).OnlyIfChanged()
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ tt.validateFunc(report, ran)
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestRunGuard() {
+ tests := []struct {
+ name string
+ guard func(orchestrator.Results) bool
+ validateFunc func(report *orchestrator.Report, ran bool)
+ }{
+ {
+ name: "skips when guard returns false",
+ guard: func(_ orchestrator.Results) bool { return false },
+ validateFunc: func(report *orchestrator.Report, ran bool) {
+ s.False(ran)
+ s.Equal(orchestrator.StatusSkipped, report.Tasks[1].Status)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(nil)
+ ran := false
+
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(true, func() {
+ ran = true
+ }))
+ b.DependsOn(a)
+ b.When(tt.guard)
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ tt.validateFunc(report, ran)
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestRunGuardWithFailedDependency() {
+ tests := []struct {
+ name string
+ guard func(orchestrator.Results) bool
+ expectRan bool
+ expectStatus orchestrator.Status
+ }{
+ {
+ name: "guard runs and returns true when dependency failed",
+ guard: func(r orchestrator.Results) bool {
+ res := r.Get("fail")
+
+ return res != nil && res.Status == orchestrator.StatusFailed
+ },
+ expectRan: true,
+ expectStatus: orchestrator.StatusChanged,
+ },
+ {
+ name: "guard runs and returns false when dependency failed",
+ guard: func(r orchestrator.Results) bool {
+ res := r.Get("fail")
+
+ return res != nil && res.Status == orchestrator.StatusChanged
+ },
+ expectRan: false,
+ expectStatus: orchestrator.StatusSkipped,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+ ran := false
+
+ fail := plan.TaskFunc("fail", failFunc("boom"))
+ alert := plan.TaskFunc("alert", taskFunc(true, func() {
+ ran = true
+ }))
+ alert.DependsOn(fail)
+ alert.When(tt.guard)
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ s.Equal(tt.expectRan, ran)
+
+ sm := statusMap(report)
+ s.Equal(tt.expectStatus, sm["alert"])
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestRunErrorStrategy() {
+ s.Run("stop all on error", func() {
+ plan := orchestrator.NewPlan(nil)
+ didRun := false
+
+ fail := plan.TaskFunc("fail", failFunc("boom"))
+ next := plan.TaskFunc("next", taskFunc(false, func() {
+ didRun = true
+ }))
+ next.DependsOn(fail)
+
+ _, err := plan.Run(context.Background())
+ s.Error(err)
+ s.False(didRun)
+ })
+
+ s.Run("continue on error runs independent tasks", func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+ didRun := false
+
+ plan.TaskFunc("fail", failFunc("boom"))
+ plan.TaskFunc("independent", taskFunc(true, func() {
+ didRun = true
+ }))
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.True(didRun)
+ s.Len(report.Tasks, 2)
+ })
+
+ s.Run("continue skips dependents of failed task", func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ a := plan.TaskFunc("a", failFunc("a failed"))
+ plan.TaskFunc("b", taskFunc(true, nil)).DependsOn(a)
+ plan.TaskFunc("c", taskFunc(true, nil))
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.Len(report.Tasks, 3)
+ m := statusMap(report)
+ s.Equal(orchestrator.StatusFailed, m["a"])
+ s.Equal(orchestrator.StatusSkipped, m["b"])
+ s.Equal(orchestrator.StatusChanged, m["c"])
+ })
+
+ s.Run("continue transitive skip", func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ a := plan.TaskFunc("a", failFunc("a failed"))
+ b := plan.TaskFunc("b", taskFunc(true, nil))
+ b.DependsOn(a)
+ c := plan.TaskFunc("c", taskFunc(true, nil))
+ c.DependsOn(b)
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ m := statusMap(report)
+ s.Equal(orchestrator.StatusFailed, m["a"])
+ s.Equal(orchestrator.StatusSkipped, m["b"])
+ s.Equal(orchestrator.StatusSkipped, m["c"])
+ })
+
+ s.Run("per-task continue override", func() {
+ plan := orchestrator.NewPlan(nil) // default StopAll
+
+ a := plan.TaskFunc("a", failFunc("a failed"))
+ a.OnError(orchestrator.Continue)
+ plan.TaskFunc("b", taskFunc(true, nil))
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ m := statusMap(report)
+ s.Equal(orchestrator.StatusFailed, m["a"])
+ s.Equal(orchestrator.StatusChanged, m["b"])
+ })
+
+ s.Run("retry succeeds after transient failure", func() {
+ attempts := 0
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Retry(2)),
+ )
+
+ plan.TaskFunc("flaky", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ attempts++
+ if attempts < 3 {
+ return nil, fmt.Errorf("attempt %d failed", attempts)
+ }
+
+ return &orchestrator.Result{Changed: true}, nil
+ })
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.Equal(3, attempts)
+ s.Equal(orchestrator.StatusChanged, report.Tasks[0].Status)
+ })
+
+ s.Run("retry exhausted returns error", func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Retry(1)),
+ )
+
+ plan.TaskFunc("always-fail", failFunc("permanent failure"))
+
+ report, err := plan.Run(context.Background())
+ s.Error(err)
+ s.Equal(orchestrator.StatusFailed, report.Tasks[0].Status)
+ })
+
+ s.Run("per-task retry override", func() {
+ attempts := 0
+ plan := orchestrator.NewPlan(nil) // default StopAll
+
+ plan.TaskFunc("flaky", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ attempts++
+ if attempts < 2 {
+ return nil, fmt.Errorf("attempt %d failed", attempts)
+ }
+
+ return &orchestrator.Result{Changed: true}, nil
+ }).OnError(orchestrator.Retry(1))
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.Equal(2, attempts)
+ s.Equal(orchestrator.StatusChanged, report.Tasks[0].Status)
+ })
+}
+
+func (s *PlanPublicTestSuite) TestRunOpTask() {
+ tests := []struct {
+ name string
+ createCode int
+ createErrMsg string
+ pollResponses []pollResponse
+ op *orchestrator.Op
+ noServer bool
+ validateFunc func(report *orchestrator.Report, err error)
+ }{
+ {
+ name: "completed with changed true in result data",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "pending"},
+ {status: "completed", result: map[string]any{
+ "changed": true,
+ "success": true,
+ "message": "DNS updated",
+ }},
+ },
+ op: &orchestrator.Op{
+ Operation: "network.dns.update",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Len(report.Tasks, 1)
+ s.Equal(orchestrator.StatusChanged, report.Tasks[0].Status)
+ s.True(report.Tasks[0].Changed)
+ },
+ },
+ {
+ name: "completed with changed false in result data",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "pending"},
+ {status: "completed", result: map[string]any{
+ "hostname": "web-01",
+ "changed": false,
+ }},
+ },
+ op: &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Len(report.Tasks, 1)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[0].Status)
+ s.False(report.Tasks[0].Changed)
+ },
+ },
+ {
+ name: "completed with no changed field defaults to false",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "completed", result: map[string]any{"hostname": "web-01"}},
+ },
+ op: &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[0].Status)
+ s.False(report.Tasks[0].Changed)
+ },
+ },
+ {
+ name: "completed with no result data defaults to unchanged",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "completed"},
+ },
+ op: &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[0].Status)
+ s.False(report.Tasks[0].Changed)
+ },
+ },
+ {
+ name: "completed with non-map result defaults to unchanged",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "completed", result: "just a string"},
+ },
+ op: &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[0].Status)
+ s.False(report.Tasks[0].Changed)
+ },
+ },
+ {
+ name: "polls past nil status",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: ""},
+ {status: "completed", result: map[string]any{"changed": true}},
+ },
+ op: &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Require().NoError(err)
+ s.Equal(orchestrator.StatusChanged, report.Tasks[0].Status)
+ },
+ },
+ {
+ name: "requires client",
+ noServer: true,
+ op: &orchestrator.Op{
+ Operation: "command.exec",
+ Target: "_any",
+ Params: map[string]any{"command": "uptime"},
+ },
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.NotNil(report)
+ s.Contains(report.Tasks[0].Error.Error(), "requires an OSAPI client")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ if tt.noServer {
+ plan := orchestrator.NewPlan(nil)
+ plan.Task("op-task", tt.op)
+
+ report, err := plan.Run(context.Background())
+ tt.validateFunc(report, err)
+
+ return
+ }
+
+ restore := withShortPoll()
+ defer restore()
+
+ srv := opServer(s, tt.createCode, tt.createErrMsg, tt.pollResponses)
+ defer srv.Close()
+
+ client := osapiclient.New(srv.URL, "test-token")
+
+ plan := orchestrator.NewPlan(client)
+ plan.Task("op-task", tt.op)
+
+ report, runErr := plan.Run(context.Background())
+ tt.validateFunc(report, runErr)
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestRunOpTaskParams() {
+ var receivedBody map[string]any
+
+ srv := httptest.NewServer(http.HandlerFunc(func(
+ w http.ResponseWriter,
+ r *http.Request,
+ ) {
+ switch {
+ case r.Method == "POST" && r.URL.Path == "/job":
+ _ = json.NewDecoder(r.Body).Decode(&receivedBody)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "job_id": "00000000-0000-0000-0000-00000000000a",
+ "status": "pending",
+ })
+ case r.Method == "GET":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "id": "00000000-0000-0000-0000-00000000000a",
+ "status": "completed",
+ })
+ }
+ }))
+ defer srv.Close()
+
+ restore := withShortPoll()
+ defer restore()
+
+ client := osapiclient.New(srv.URL, "test-token")
+
+ plan := orchestrator.NewPlan(client)
+ plan.Task("exec", &orchestrator.Op{
+ Operation: "command.exec.execute",
+ Target: "_any",
+ Params: map[string]any{
+ "command": "uptime",
+ "args": []string{"-s"},
+ },
+ })
+
+ report, err := plan.Run(context.Background())
+ s.Require().NoError(err)
+ s.Equal(orchestrator.StatusUnchanged, report.Tasks[0].Status)
+
+ op, ok := receivedBody["operation"].(map[string]any)
+ s.Require().True(ok)
+ s.Equal("command.exec.execute", op["type"])
+
+ data, ok := op["data"].(map[string]any)
+ s.Require().True(ok)
+ s.Equal("uptime", data["command"])
+}
+
+func (s *PlanPublicTestSuite) TestRunOpTaskErrors() {
+ tests := []struct {
+ name string
+ createCode int
+ createErrMsg string
+ pollResponses []pollResponse
+ useServer bool
+ networkError string // "create" or "poll"
+ useContext bool
+ pollInterval time.Duration // overrides withShortPoll if set
+ validateFunc func(report *orchestrator.Report, err error)
+ }{
+ {
+ name: "create returns server error",
+ createCode: http.StatusInternalServerError,
+ createErrMsg: "internal server error",
+ useServer: true,
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.NotNil(report)
+ s.Equal(orchestrator.StatusFailed, report.Tasks[0].Status)
+ s.Contains(err.Error(), "internal server error")
+ },
+ },
+ {
+ name: "create returns validation error",
+ createCode: http.StatusBadRequest,
+ createErrMsg: "validation failed on 'target_hostname' for 'valid_target'",
+ useServer: true,
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.NotNil(report)
+ s.Equal(orchestrator.StatusFailed, report.Tasks[0].Status)
+ s.Contains(err.Error(), "valid_target")
+ },
+ },
+ {
+ name: "poll returns server error",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {code: http.StatusInternalServerError},
+ },
+ useServer: true,
+ validateFunc: func(_ *orchestrator.Report, err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "poll job")
+ },
+ },
+ {
+ name: "job failed with error message",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "failed", err: "disk full"},
+ },
+ useServer: true,
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "disk full")
+ s.Equal(orchestrator.StatusFailed, report.Tasks[0].Status)
+ },
+ },
+ {
+ name: "job failed without error message",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "failed"},
+ },
+ useServer: true,
+ validateFunc: func(_ *orchestrator.Report, err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "job failed")
+ },
+ },
+ {
+ name: "context canceled during poll",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "pending"},
+ },
+ useServer: true,
+ useContext: true,
+ validateFunc: func(_ *orchestrator.Report, err error) {
+ s.Error(err)
+ s.ErrorIs(err, context.DeadlineExceeded)
+ },
+ },
+ {
+ name: "create network error",
+ networkError: "create",
+ validateFunc: func(_ *orchestrator.Report, err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "create job")
+ },
+ },
+ {
+ name: "poll network error",
+ networkError: "poll",
+ validateFunc: func(_ *orchestrator.Report, err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "poll job")
+ },
+ },
+ {
+ name: "create returns unexpected status",
+ createCode: http.StatusOK,
+ useServer: true,
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.NotNil(report)
+ s.Contains(err.Error(), "nil response body")
+ },
+ },
+ {
+ name: "context canceled before first poll tick",
+ createCode: http.StatusCreated,
+ pollResponses: []pollResponse{
+ {status: "pending"},
+ },
+ useServer: true,
+ useContext: true,
+ pollInterval: 5 * time.Second,
+ validateFunc: func(report *orchestrator.Report, err error) {
+ s.Error(err)
+ s.NotNil(report)
+ s.ErrorIs(err, context.DeadlineExceeded)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ var restore func()
+ if tt.pollInterval > 0 {
+ orig := orchestrator.DefaultPollInterval
+ orchestrator.DefaultPollInterval = tt.pollInterval
+ restore = func() { orchestrator.DefaultPollInterval = orig }
+ } else {
+ restore = withShortPoll()
+ }
+ defer restore()
+
+ var srv *httptest.Server
+
+ switch {
+ case tt.networkError == "create":
+ srv = httptest.NewServer(http.HandlerFunc(func(
+ w http.ResponseWriter,
+ _ *http.Request,
+ ) {
+ hj, ok := w.(http.Hijacker)
+ if ok {
+ conn, _, _ := hj.Hijack()
+ _ = conn.Close()
+ }
+ }))
+ case tt.networkError == "poll":
+ srv = httptest.NewServer(http.HandlerFunc(func(
+ w http.ResponseWriter,
+ r *http.Request,
+ ) {
+ if r.Method == "POST" && r.URL.Path == "/job" {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "job_id": "00000000-0000-0000-0000-000000000009",
+ "status": "pending",
+ })
+
+ return
+ }
+
+ hj, ok := w.(http.Hijacker)
+ if ok {
+ conn, _, _ := hj.Hijack()
+ _ = conn.Close()
+ }
+ }))
+ case tt.useServer:
+ srv = opServer(s, tt.createCode, tt.createErrMsg, tt.pollResponses)
+ }
+
+ defer srv.Close()
+
+ client := osapiclient.New(srv.URL, "test-token")
+
+ plan := orchestrator.NewPlan(client)
+ plan.Task("op-task", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ Target: "_any",
+ })
+
+ ctx := context.Background()
+ if tt.useContext {
+ var cancel context.CancelFunc
+ ctx, cancel = context.WithTimeout(ctx, 50*time.Millisecond)
+ defer cancel()
+ }
+
+ report, runErr := plan.Run(ctx)
+ tt.validateFunc(report, runErr)
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestRunHooks() {
+ s.Run("all hooks called in order", func() {
+ var events []string
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(nil, orchestrator.WithHooks(hooks))
+
+ a := plan.TaskFunc("a", taskFunc(true, nil))
+ plan.TaskFunc("b", taskFunc(false, nil)).DependsOn(a)
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.NotNil(report)
+ s.Equal([]string{
+ "before-plan",
+ "before-level-0",
+ "before-a",
+ "after-a",
+ "after-level-0",
+ "before-level-1",
+ "before-b",
+ "after-b",
+ "after-level-1",
+ "after-plan",
+ }, events)
+ })
+
+ s.Run("retry hook called with correct args", func() {
+ var events []string
+ attempts := 0
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.WithHooks(hooks),
+ orchestrator.OnError(orchestrator.Retry(2)),
+ )
+
+ plan.TaskFunc("flaky", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ attempts++
+ if attempts < 3 {
+ return nil, fmt.Errorf("fail-%d", attempts)
+ }
+
+ return &orchestrator.Result{Changed: true}, nil
+ })
+
+ _, err := plan.Run(context.Background())
+ s.NoError(err)
+
+ retries := filterPrefix(events, "retry-")
+ s.Len(retries, 2)
+ s.Contains(retries[0], "retry-flaky-1-fail-1")
+ s.Contains(retries[1], "retry-flaky-2-fail-2")
+ })
+
+ s.Run("skip hook for dependency failure", func() {
+ var events []string
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.WithHooks(hooks),
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ a := plan.TaskFunc("a", failFunc("a failed"))
+ plan.TaskFunc("b", taskFunc(true, nil)).DependsOn(a)
+
+ _, err := plan.Run(context.Background())
+ s.NoError(err)
+
+ skips := filterPrefix(events, "skip-")
+ s.Len(skips, 1)
+ s.Contains(skips[0], "skip-b-dependency failed")
+ })
+
+ s.Run("skip hook for guard", func() {
+ var events []string
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(nil, orchestrator.WithHooks(hooks))
+
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(true, nil))
+ b.DependsOn(a)
+ b.When(func(_ orchestrator.Results) bool { return false })
+
+ _, err := plan.Run(context.Background())
+ s.NoError(err)
+
+ skips := filterPrefix(events, "skip-")
+ s.Len(skips, 1)
+ s.Contains(skips[0], "guard returned false")
+ })
+
+ s.Run("skip hook for guard with custom reason", func() {
+ var events []string
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(nil, orchestrator.WithHooks(hooks))
+
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(true, nil))
+ b.DependsOn(a)
+ b.WhenWithReason(
+ func(_ orchestrator.Results) bool { return false },
+ "host is unreachable",
+ )
+
+ _, err := plan.Run(context.Background())
+ s.NoError(err)
+
+ skips := filterPrefix(events, "skip-")
+ s.Len(skips, 1)
+ s.Contains(skips[0], "host is unreachable")
+ })
+
+ s.Run("skip hook for only-if-changed", func() {
+ var events []string
+
+ hooks := allHooks(&events)
+ plan := orchestrator.NewPlan(nil, orchestrator.WithHooks(hooks))
+
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(true, nil))
+ b.DependsOn(a)
+ b.OnlyIfChanged()
+
+ _, err := plan.Run(context.Background())
+ s.NoError(err)
+
+ skips := filterPrefix(events, "skip-")
+ s.Len(skips, 1)
+ s.Contains(skips[0], "no dependencies changed")
+ })
+
+ s.Run("retry without hooks configured", func() {
+ attempts := 0
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Retry(1)),
+ )
+
+ plan.TaskFunc("flaky", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ attempts++
+ if attempts < 2 {
+ return nil, fmt.Errorf("attempt %d", attempts)
+ }
+
+ return &orchestrator.Result{Changed: true}, nil
+ })
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ s.Equal(2, attempts)
+ s.Equal(orchestrator.StatusChanged, report.Tasks[0].Status)
+ })
+
+ s.Run("skip without hooks configured", func() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ a := plan.TaskFunc("a", failFunc("a failed"))
+ plan.TaskFunc("b", taskFunc(true, nil)).DependsOn(a)
+
+ report, err := plan.Run(context.Background())
+ s.NoError(err)
+ m := statusMap(report)
+ s.Equal(orchestrator.StatusSkipped, m["b"])
+ })
+}
+
+func (s *PlanPublicTestSuite) TestClient() {
+ client := osapiclient.New("http://localhost", "token")
+
+ plan := orchestrator.NewPlan(client)
+ s.Equal(client, plan.Client())
+
+ nilPlan := orchestrator.NewPlan(nil)
+ s.Nil(nilPlan.Client())
+}
+
+func (s *PlanPublicTestSuite) TestConfig() {
+ plan := orchestrator.NewPlan(
+ nil,
+ orchestrator.OnError(orchestrator.Continue),
+ )
+
+ cfg := plan.Config()
+ s.Equal("continue", cfg.OnErrorStrategy.String())
+}
+
+func (s *PlanPublicTestSuite) TestTasks() {
+ plan := orchestrator.NewPlan(nil)
+ s.Empty(plan.Tasks())
+
+ plan.TaskFunc("a", taskFunc(false, nil))
+ plan.TaskFunc("b", taskFunc(false, nil))
+ s.Len(plan.Tasks(), 2)
+}
+
+func (s *PlanPublicTestSuite) TestValidate() {
+ tests := []struct {
+ name string
+ setup func(plan *orchestrator.Plan)
+ validateFunc func(err error)
+ }{
+ {
+ name: "duplicate task name returns error",
+ setup: func(plan *orchestrator.Plan) {
+ plan.TaskFunc("dup", taskFunc(false, nil))
+ plan.TaskFunc("dup", taskFunc(false, nil))
+ },
+ validateFunc: func(err error) {
+ s.Error(err)
+ s.Contains(err.Error(), "duplicate task name")
+ },
+ },
+ {
+ name: "valid plan returns nil",
+ setup: func(plan *orchestrator.Plan) {
+ plan.TaskFunc("a", taskFunc(false, nil))
+ plan.TaskFunc("b", taskFunc(false, nil))
+ },
+ validateFunc: func(err error) {
+ s.NoError(err)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(nil)
+ tt.setup(plan)
+ tt.validateFunc(plan.Validate())
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestLevels() {
+ tests := []struct {
+ name string
+ setup func(plan *orchestrator.Plan)
+ validateFunc func(levels [][]*orchestrator.Task, err error)
+ }{
+ {
+ name: "returns levels for valid plan",
+ setup: func(plan *orchestrator.Plan) {
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ plan.TaskFunc("b", taskFunc(false, nil)).DependsOn(a)
+ },
+ validateFunc: func(levels [][]*orchestrator.Task, err error) {
+ s.NoError(err)
+ s.Len(levels, 2)
+ },
+ },
+ {
+ name: "returns error for invalid plan",
+ setup: func(plan *orchestrator.Plan) {
+ a := plan.Task("a", &orchestrator.Op{Operation: "noop"})
+ b := plan.Task("b", &orchestrator.Op{Operation: "noop"})
+ a.DependsOn(b)
+ b.DependsOn(a)
+ },
+ validateFunc: func(levels [][]*orchestrator.Task, err error) {
+ s.Error(err)
+ s.Nil(levels)
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(nil)
+ tt.setup(plan)
+ levels, err := plan.Levels()
+ tt.validateFunc(levels, err)
+ })
+ }
+}
+
+func (s *PlanPublicTestSuite) TestExplain() {
+ tests := []struct {
+ name string
+ setup func(plan *orchestrator.Plan)
+ contains []string
+ }{
+ {
+ name: "valid plan with dependencies and guards",
+ setup: func(plan *orchestrator.Plan) {
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(false, nil))
+ b.DependsOn(a)
+ b.OnlyIfChanged()
+ },
+ contains: []string{
+ "Plan: 2 tasks, 2 levels",
+ "Level 0:",
+ "a [fn]",
+ "Level 1:",
+ "b [fn]",
+ "only-if-changed",
+ },
+ },
+ {
+ name: "invalid plan returns error string",
+ setup: func(plan *orchestrator.Plan) {
+ a := plan.Task("a", &orchestrator.Op{Operation: "noop"})
+ b := plan.Task("b", &orchestrator.Op{Operation: "noop"})
+ a.DependsOn(b)
+ b.DependsOn(a)
+ },
+ contains: []string{"invalid plan:", "cycle"},
+ },
+ {
+ name: "parallel tasks shown as parallel",
+ setup: func(plan *orchestrator.Plan) {
+ plan.TaskFunc("a", taskFunc(false, nil))
+ plan.TaskFunc("b", taskFunc(false, nil))
+ },
+ contains: []string{
+ "Plan: 2 tasks, 1 levels",
+ "Level 0 (parallel):",
+ },
+ },
+ {
+ name: "op task shown as op",
+ setup: func(plan *orchestrator.Plan) {
+ plan.Task("install", &orchestrator.Op{
+ Operation: "node.hostname.get",
+ })
+ },
+ contains: []string{"install [op]"},
+ },
+ {
+ name: "guard shown in flags",
+ setup: func(plan *orchestrator.Plan) {
+ a := plan.TaskFunc("a", taskFunc(false, nil))
+ b := plan.TaskFunc("b", taskFunc(false, nil))
+ b.DependsOn(a)
+ b.When(func(_ orchestrator.Results) bool { return true })
+ },
+ contains: []string{"when"},
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := orchestrator.NewPlan(nil)
+ tt.setup(plan)
+ output := plan.Explain()
+ for _, c := range tt.contains {
+ s.Contains(output, c)
+ }
+ })
+ }
+}
+
+// allHooks returns a Hooks struct that appends all events to the given
+// slice, covering every hook type.
+func allHooks(events *[]string) orchestrator.Hooks {
+ return orchestrator.Hooks{
+ BeforePlan: func(_ orchestrator.PlanSummary) {
+ *events = append(*events, "before-plan")
+ },
+ AfterPlan: func(_ *orchestrator.Report) {
+ *events = append(*events, "after-plan")
+ },
+ BeforeLevel: func(level int, _ []*orchestrator.Task, _ bool) {
+ *events = append(*events, fmt.Sprintf("before-level-%d", level))
+ },
+ AfterLevel: func(level int, _ []orchestrator.TaskResult) {
+ *events = append(*events, fmt.Sprintf("after-level-%d", level))
+ },
+ BeforeTask: func(task *orchestrator.Task) {
+ *events = append(*events, "before-"+task.Name())
+ },
+ AfterTask: func(_ *orchestrator.Task, r orchestrator.TaskResult) {
+ *events = append(*events, "after-"+r.Name)
+ },
+ OnRetry: func(
+ task *orchestrator.Task,
+ attempt int,
+ err error,
+ ) {
+ *events = append(
+ *events,
+ fmt.Sprintf("retry-%s-%d-%s", task.Name(), attempt, err),
+ )
+ },
+ OnSkip: func(task *orchestrator.Task, reason string) {
+ *events = append(
+ *events,
+ fmt.Sprintf("skip-%s-%s", task.Name(), reason),
+ )
+ },
+ }
+}
diff --git a/pkg/sdk/orchestrator/result.go b/pkg/sdk/orchestrator/result.go
new file mode 100644
index 00000000..1ad5c7de
--- /dev/null
+++ b/pkg/sdk/orchestrator/result.go
@@ -0,0 +1,120 @@
+// Package orchestrator provides DAG-based task orchestration primitives.
+package orchestrator
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+// Status represents the outcome of a task execution.
+type Status string
+
+// Task execution statuses.
+const (
+ // StatusPending and StatusRunning are reserved for future
+ // streaming status support. The runner does not currently
+ // assign these — tasks go directly to a terminal status.
+ StatusPending Status = "pending"
+ StatusRunning Status = "running"
+ StatusChanged Status = "changed"
+ StatusUnchanged Status = "unchanged"
+ StatusSkipped Status = "skipped"
+ StatusFailed Status = "failed"
+)
+
+// HostResult represents a single host's response within a broadcast
+// operation.
+type HostResult struct {
+ Hostname string
+ Changed bool
+ Error string
+ Data map[string]any
+}
+
+// Result is the outcome of a single task execution.
+type Result struct {
+ Changed bool
+ Data map[string]any
+ Status Status
+ HostResults []HostResult
+}
+
+// TaskResult records the full execution details of a task.
+type TaskResult struct {
+ Name string
+ Status Status
+ Changed bool
+ Duration time.Duration
+ Error error
+ Data map[string]any
+ HostResults []HostResult
+}
+
+// Results is a map of task name to Result, used for conditional logic.
+type Results map[string]*Result
+
+// Get returns the Result for the named task, or nil if not found.
+func (r Results) Get(
+ name string,
+) *Result {
+ return r[name]
+}
+
+// StepSummary describes a single execution step (DAG level).
+type StepSummary struct {
+ Tasks []string
+ Parallel bool
+}
+
+// PlanSummary describes the execution plan before it runs.
+type PlanSummary struct {
+ TotalTasks int
+ Steps []StepSummary
+}
+
+// Report is the aggregate output of a plan execution.
+type Report struct {
+ Tasks []TaskResult
+ Duration time.Duration
+}
+
+// Summary returns a human-readable summary of the report.
+func (r *Report) Summary() string {
+ var changed, unchanged, skipped, failed int
+
+ for _, t := range r.Tasks {
+ switch t.Status {
+ case StatusChanged:
+ changed++
+ case StatusUnchanged:
+ unchanged++
+ case StatusSkipped:
+ skipped++
+ case StatusFailed:
+ failed++
+ }
+ }
+
+ parts := []string{
+ fmt.Sprintf("%d tasks", len(r.Tasks)),
+ }
+
+ if changed > 0 {
+ parts = append(parts, fmt.Sprintf("%d changed", changed))
+ }
+
+ if unchanged > 0 {
+ parts = append(parts, fmt.Sprintf("%d unchanged", unchanged))
+ }
+
+ if skipped > 0 {
+ parts = append(parts, fmt.Sprintf("%d skipped", skipped))
+ }
+
+ if failed > 0 {
+ parts = append(parts, fmt.Sprintf("%d failed", failed))
+ }
+
+ return strings.Join(parts, ", ")
+}
diff --git a/pkg/sdk/orchestrator/result_public_test.go b/pkg/sdk/orchestrator/result_public_test.go
new file mode 100644
index 00000000..41d92307
--- /dev/null
+++ b/pkg/sdk/orchestrator/result_public_test.go
@@ -0,0 +1,251 @@
+package orchestrator_test
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+type ResultPublicTestSuite struct {
+ suite.Suite
+}
+
+func TestResultPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(ResultPublicTestSuite))
+}
+
+func (s *ResultPublicTestSuite) TestReportSummary() {
+ tests := []struct {
+ name string
+ tasks []orchestrator.TaskResult
+ contains []string
+ }{
+ {
+ name: "mixed results",
+ tasks: []orchestrator.TaskResult{
+ {
+ Name: "a",
+ Status: orchestrator.StatusChanged,
+ Changed: true,
+ Duration: time.Second,
+ },
+ {
+ Name: "b",
+ Status: orchestrator.StatusUnchanged,
+ Changed: false,
+ Duration: 2 * time.Second,
+ },
+ {Name: "c", Status: orchestrator.StatusSkipped, Changed: false, Duration: 0},
+ {
+ Name: "d",
+ Status: orchestrator.StatusChanged,
+ Changed: true,
+ Duration: 500 * time.Millisecond,
+ },
+ },
+ contains: []string{"4 tasks", "2 changed", "1 unchanged", "1 skipped"},
+ },
+ {
+ name: "all statuses including failed",
+ tasks: []orchestrator.TaskResult{
+ {Name: "a", Status: orchestrator.StatusChanged, Changed: true},
+ {Name: "b", Status: orchestrator.StatusUnchanged},
+ {Name: "c", Status: orchestrator.StatusSkipped},
+ {Name: "d", Status: orchestrator.StatusFailed},
+ },
+ contains: []string{"4 tasks", "1 changed", "1 unchanged", "1 skipped", "1 failed"},
+ },
+ {
+ name: "empty report",
+ tasks: nil,
+ contains: []string{"0 tasks"},
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ report := orchestrator.Report{Tasks: tt.tasks}
+ summary := report.Summary()
+ for _, c := range tt.contains {
+ s.Contains(summary, c)
+ }
+ })
+ }
+}
+
+func (s *ResultPublicTestSuite) TestResultStatusField() {
+ tests := []struct {
+ name string
+ result *orchestrator.Result
+ wantStatus orchestrator.Status
+ wantChange bool
+ }{
+ {
+ name: "changed result carries status",
+ result: &orchestrator.Result{
+ Changed: true,
+ Data: map[string]any{"hostname": "web-01"},
+ Status: orchestrator.StatusChanged,
+ },
+ wantStatus: orchestrator.StatusChanged,
+ wantChange: true,
+ },
+ {
+ name: "unchanged result carries status",
+ result: &orchestrator.Result{
+ Changed: false,
+ Status: orchestrator.StatusUnchanged,
+ },
+ wantStatus: orchestrator.StatusUnchanged,
+ wantChange: false,
+ },
+ {
+ name: "failed result carries status",
+ result: &orchestrator.Result{
+ Changed: false,
+ Status: orchestrator.StatusFailed,
+ },
+ wantStatus: orchestrator.StatusFailed,
+ wantChange: false,
+ },
+ {
+ name: "skipped result carries status",
+ result: &orchestrator.Result{
+ Changed: false,
+ Status: orchestrator.StatusSkipped,
+ },
+ wantStatus: orchestrator.StatusSkipped,
+ wantChange: false,
+ },
+ {
+ name: "zero value has empty status",
+ result: &orchestrator.Result{},
+ wantStatus: "",
+ wantChange: false,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ s.Equal(tt.wantStatus, tt.result.Status)
+ s.Equal(tt.wantChange, tt.result.Changed)
+ })
+ }
+}
+
+func (s *ResultPublicTestSuite) TestResultHostResults() {
+ tests := []struct {
+ name string
+ result *orchestrator.Result
+ wantLen int
+ validateFn func(hrs []orchestrator.HostResult)
+ }{
+ {
+ name: "result with multiple host results",
+ result: &orchestrator.Result{
+ Changed: true,
+ Status: orchestrator.StatusChanged,
+ HostResults: []orchestrator.HostResult{
+ {
+ Hostname: "web-01",
+ Changed: true,
+ Data: map[string]any{"stdout": "ok"},
+ },
+ {
+ Hostname: "web-02",
+ Changed: false,
+ Error: "connection timeout",
+ },
+ },
+ },
+ wantLen: 2,
+ validateFn: func(hrs []orchestrator.HostResult) {
+ s.Equal("web-01", hrs[0].Hostname)
+ s.True(hrs[0].Changed)
+ s.Equal("web-02", hrs[1].Hostname)
+ s.Equal("connection timeout", hrs[1].Error)
+ },
+ },
+ {
+ name: "result with no host results",
+ result: &orchestrator.Result{
+ Changed: false,
+ Status: orchestrator.StatusUnchanged,
+ },
+ wantLen: 0,
+ },
+ {
+ name: "host result with data map",
+ result: &orchestrator.Result{
+ Changed: true,
+ Status: orchestrator.StatusChanged,
+ HostResults: []orchestrator.HostResult{
+ {
+ Hostname: "db-01",
+ Changed: true,
+ Data: map[string]any{
+ "stdout": "migrated",
+ "exit_code": float64(0),
+ },
+ },
+ },
+ },
+ wantLen: 1,
+ validateFn: func(hrs []orchestrator.HostResult) {
+ s.Equal("db-01", hrs[0].Hostname)
+ s.Equal("migrated", hrs[0].Data["stdout"])
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ s.Len(tt.result.HostResults, tt.wantLen)
+
+ if tt.validateFn != nil {
+ tt.validateFn(tt.result.HostResults)
+ }
+ })
+ }
+}
+
+func (s *ResultPublicTestSuite) TestResultsGet() {
+ tests := []struct {
+ name string
+ results orchestrator.Results
+ lookupName string
+ wantNil bool
+ wantChange bool
+ }{
+ {
+ name: "found",
+ results: orchestrator.Results{
+ "install": {Changed: true},
+ },
+ lookupName: "install",
+ wantNil: false,
+ wantChange: true,
+ },
+ {
+ name: "not found",
+ results: orchestrator.Results{},
+ lookupName: "missing",
+ wantNil: true,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ got := tt.results.Get(tt.lookupName)
+ if tt.wantNil {
+ s.Nil(got)
+ } else {
+ s.Require().NotNil(got)
+ s.Equal(tt.wantChange, got.Changed)
+ }
+ })
+ }
+}
diff --git a/pkg/sdk/orchestrator/runner.go b/pkg/sdk/orchestrator/runner.go
new file mode 100644
index 00000000..68188523
--- /dev/null
+++ b/pkg/sdk/orchestrator/runner.go
@@ -0,0 +1,598 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+)
+
+// runner executes a validated plan.
+type runner struct {
+ plan *Plan
+ results Results
+ failed map[string]bool
+ mu sync.Mutex
+}
+
+// newRunner creates a runner for the plan.
+func newRunner(
+ plan *Plan,
+) *runner {
+ return &runner{
+ plan: plan,
+ results: make(Results),
+ failed: make(map[string]bool),
+ }
+}
+
+// run executes the plan by levelizing the DAG and running each
+// level in parallel.
+func (r *runner) run(
+ ctx context.Context,
+) (*Report, error) {
+ start := time.Now()
+ levels := levelize(r.plan.tasks)
+
+ r.callBeforePlan(buildPlanSummary(r.plan.tasks, levels))
+
+ var taskResults []TaskResult
+
+ for i, level := range levels {
+ r.callBeforeLevel(i, level, len(level) > 1)
+
+ results, err := r.runLevel(ctx, level)
+ taskResults = append(taskResults, results...)
+
+ r.callAfterLevel(i, results)
+
+ if err != nil {
+ report := &Report{
+ Tasks: taskResults,
+ Duration: time.Since(start),
+ }
+
+ r.callAfterPlan(report)
+
+ return report, err
+ }
+ }
+
+ report := &Report{
+ Tasks: taskResults,
+ Duration: time.Since(start),
+ }
+
+ r.callAfterPlan(report)
+
+ return report, nil
+}
+
+// hook returns the plan's hooks or nil.
+func (r *runner) hook() *Hooks {
+ return r.plan.config.Hooks
+}
+
+// callBeforePlan invokes the BeforePlan hook if set.
+func (r *runner) callBeforePlan(
+ summary PlanSummary,
+) {
+ if h := r.hook(); h != nil && h.BeforePlan != nil {
+ h.BeforePlan(summary)
+ }
+}
+
+// buildPlanSummary creates a PlanSummary from tasks and levels.
+func buildPlanSummary(
+ tasks []*Task,
+ levels [][]*Task,
+) PlanSummary {
+ steps := make([]StepSummary, len(levels))
+ for i, level := range levels {
+ names := make([]string, len(level))
+ for j, t := range level {
+ names[j] = t.name
+ }
+
+ steps[i] = StepSummary{
+ Tasks: names,
+ Parallel: len(level) > 1,
+ }
+ }
+
+ return PlanSummary{
+ TotalTasks: len(tasks),
+ Steps: steps,
+ }
+}
+
+// callAfterPlan invokes the AfterPlan hook if set.
+func (r *runner) callAfterPlan(
+ report *Report,
+) {
+ if h := r.hook(); h != nil && h.AfterPlan != nil {
+ h.AfterPlan(report)
+ }
+}
+
+// callBeforeLevel invokes the BeforeLevel hook if set.
+func (r *runner) callBeforeLevel(
+ level int,
+ tasks []*Task,
+ parallel bool,
+) {
+ if h := r.hook(); h != nil && h.BeforeLevel != nil {
+ h.BeforeLevel(level, tasks, parallel)
+ }
+}
+
+// callAfterLevel invokes the AfterLevel hook if set.
+func (r *runner) callAfterLevel(
+ level int,
+ results []TaskResult,
+) {
+ if h := r.hook(); h != nil && h.AfterLevel != nil {
+ h.AfterLevel(level, results)
+ }
+}
+
+// callBeforeTask invokes the BeforeTask hook if set.
+func (r *runner) callBeforeTask(
+ task *Task,
+) {
+ if h := r.hook(); h != nil && h.BeforeTask != nil {
+ h.BeforeTask(task)
+ }
+}
+
+// callAfterTask invokes the AfterTask hook if set.
+func (r *runner) callAfterTask(
+ task *Task,
+ result TaskResult,
+) {
+ if h := r.hook(); h != nil && h.AfterTask != nil {
+ h.AfterTask(task, result)
+ }
+}
+
+// callOnRetry invokes the OnRetry hook if set.
+func (r *runner) callOnRetry(
+ task *Task,
+ attempt int,
+ err error,
+) {
+ if h := r.hook(); h != nil && h.OnRetry != nil {
+ h.OnRetry(task, attempt, err)
+ }
+}
+
+// callOnSkip invokes the OnSkip hook if set.
+func (r *runner) callOnSkip(
+ task *Task,
+ reason string,
+) {
+ if h := r.hook(); h != nil && h.OnSkip != nil {
+ h.OnSkip(task, reason)
+ }
+}
+
+// effectiveStrategy returns the error strategy for a task,
+// checking the per-task override before falling back to the
+// plan-level default.
+func (r *runner) effectiveStrategy(
+ t *Task,
+) ErrorStrategy {
+ if t.errorStrategy != nil {
+ return *t.errorStrategy
+ }
+
+ return r.plan.config.OnErrorStrategy
+}
+
+// runLevel executes all tasks in a level concurrently.
+func (r *runner) runLevel(
+ ctx context.Context,
+ tasks []*Task,
+) ([]TaskResult, error) {
+ results := make([]TaskResult, len(tasks))
+
+ var wg sync.WaitGroup
+
+ for i, t := range tasks {
+ wg.Add(1)
+
+ go func() {
+ defer wg.Done()
+
+ results[i] = r.runTask(ctx, t)
+ }()
+ }
+
+ wg.Wait()
+
+ for i, tr := range results {
+ if tr.Status == StatusFailed {
+ strategy := r.effectiveStrategy(tasks[i])
+ if strategy.kind != "continue" {
+ return results, tr.Error
+ }
+ }
+ }
+
+ return results, nil
+}
+
+// runTask executes a single task with guard checks.
+func (r *runner) runTask(
+ ctx context.Context,
+ t *Task,
+) TaskResult {
+ start := time.Now()
+
+ // Skip if any dependency failed — unless the task has a When guard,
+ // which may intentionally inspect failure status (e.g. alert-on-failure).
+ if t.guard == nil {
+ r.mu.Lock()
+
+ for _, dep := range t.deps {
+ if r.failed[dep.name] {
+ r.failed[t.name] = true
+ r.results[t.name] = &Result{Status: StatusSkipped}
+ r.mu.Unlock()
+
+ tr := TaskResult{
+ Name: t.name,
+ Status: StatusSkipped,
+ Duration: time.Since(start),
+ }
+ r.callOnSkip(t, "dependency failed")
+ r.callAfterTask(t, tr)
+
+ return tr
+ }
+ }
+
+ r.mu.Unlock()
+ }
+
+ if t.requiresChange {
+ anyChanged := false
+
+ r.mu.Lock()
+
+ for _, dep := range t.deps {
+ if res := r.results.Get(dep.name); res != nil && res.Changed {
+ anyChanged = true
+
+ break
+ }
+ }
+
+ r.mu.Unlock()
+
+ if !anyChanged {
+ r.mu.Lock()
+ r.results[t.name] = &Result{Status: StatusSkipped}
+ r.mu.Unlock()
+
+ tr := TaskResult{
+ Name: t.name,
+ Status: StatusSkipped,
+ Duration: time.Since(start),
+ }
+
+ r.callOnSkip(t, "no dependencies changed")
+ r.callAfterTask(t, tr)
+
+ return tr
+ }
+ }
+
+ if t.guard != nil {
+ r.mu.Lock()
+ shouldRun := t.guard(r.results)
+ r.mu.Unlock()
+
+ if !shouldRun {
+ r.mu.Lock()
+ r.results[t.name] = &Result{Status: StatusSkipped}
+ r.mu.Unlock()
+
+ tr := TaskResult{
+ Name: t.name,
+ Status: StatusSkipped,
+ Duration: time.Since(start),
+ }
+
+ reason := "guard returned false"
+ if t.guardReason != "" {
+ reason = t.guardReason
+ }
+ r.callOnSkip(t, reason)
+ r.callAfterTask(t, tr)
+
+ return tr
+ }
+ }
+
+ r.callBeforeTask(t)
+
+ strategy := r.effectiveStrategy(t)
+ maxAttempts := 1
+
+ if strategy.kind == "retry" {
+ maxAttempts = strategy.retryCount + 1
+ }
+
+ var result *Result
+ var err error
+
+ client := r.plan.client
+
+ for attempt := range maxAttempts {
+ if t.fnr != nil {
+ r.mu.Lock()
+ results := r.results
+ r.mu.Unlock()
+
+ result, err = t.fnr(ctx, client, results)
+ } else if t.fn != nil {
+ result, err = t.fn(ctx, client)
+ } else {
+ result, err = r.executeOp(ctx, t.op)
+ }
+
+ if err == nil {
+ break
+ }
+
+ if attempt < maxAttempts-1 {
+ r.callOnRetry(t, attempt+1, err)
+ }
+ }
+
+ elapsed := time.Since(start)
+
+ if err != nil {
+ r.mu.Lock()
+ r.failed[t.name] = true
+ r.results[t.name] = &Result{Status: StatusFailed}
+ r.mu.Unlock()
+
+ tr := TaskResult{
+ Name: t.name,
+ Status: StatusFailed,
+ Duration: elapsed,
+ Error: err,
+ }
+
+ r.callAfterTask(t, tr)
+
+ return tr
+ }
+
+ status := StatusUnchanged
+ if result.Changed {
+ status = StatusChanged
+ }
+
+ result.Status = status
+
+ r.mu.Lock()
+ r.results[t.name] = result
+ r.mu.Unlock()
+
+ tr := TaskResult{
+ Name: t.name,
+ Status: status,
+ Changed: result.Changed,
+ Duration: elapsed,
+ Data: result.Data,
+ HostResults: result.HostResults,
+ }
+
+ r.callAfterTask(t, tr)
+
+ return tr
+}
+
+// DefaultPollInterval is the interval between job status polls.
+var DefaultPollInterval = 500 * time.Millisecond
+
+// isCommandOp returns true for command execution operations.
+func isCommandOp(
+ operation string,
+) bool {
+ return operation == "command.exec.execute" ||
+ operation == "command.shell.execute"
+}
+
+// extractHostResults parses per-agent results from a broadcast
+// collection response.
+func extractHostResults(
+ data map[string]any,
+) []HostResult {
+ resultsRaw, ok := data["results"]
+ if !ok {
+ return nil
+ }
+
+ items, ok := resultsRaw.([]any)
+ if !ok {
+ return nil
+ }
+
+ hostResults := make([]HostResult, 0, len(items))
+
+ for _, item := range items {
+ m, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+
+ hr := HostResult{
+ Data: m,
+ }
+
+ if h, ok := m["hostname"].(string); ok {
+ hr.Hostname = h
+ }
+
+ if c, ok := m["changed"].(bool); ok {
+ hr.Changed = c
+ }
+
+ if e, ok := m["error"].(string); ok {
+ hr.Error = e
+ }
+
+ hostResults = append(hostResults, hr)
+ }
+
+ return hostResults
+}
+
+// executeOp submits a declarative Op as a job via the SDK and polls
+// for completion.
+func (r *runner) executeOp(
+ ctx context.Context,
+ op *Op,
+) (*Result, error) {
+ client := r.plan.client
+ if client == nil {
+ return nil, fmt.Errorf(
+ "op task %q requires an OSAPI client",
+ op.Operation,
+ )
+ }
+
+ operation := map[string]interface{}{
+ "type": op.Operation,
+ }
+
+ if len(op.Params) > 0 {
+ operation["data"] = op.Params
+ }
+
+ createResp, err := client.Job.Create(ctx, operation, op.Target)
+ if err != nil {
+ return nil, fmt.Errorf("create job: %w", err)
+ }
+
+ jobID := createResp.Data.JobID
+
+ result, err := r.pollJob(ctx, jobID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract per-host results for broadcast targets.
+ if IsBroadcastTarget(op.Target) {
+ result.HostResults = extractHostResults(result.Data)
+ }
+
+ // Non-zero exit for command operations = failure.
+ if isCommandOp(op.Operation) {
+ if exitCode, ok := result.Data["exit_code"].(float64); ok && exitCode != 0 {
+ result.Status = StatusFailed
+
+ return result, fmt.Errorf(
+ "command exited with code %d",
+ int(exitCode),
+ )
+ }
+ }
+
+ return result, nil
+}
+
+// pollJob polls a job until it reaches a terminal state.
+func (r *runner) pollJob(
+ ctx context.Context,
+ jobID string,
+) (*Result, error) {
+ ticker := time.NewTicker(DefaultPollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-ticker.C:
+ resp, err := r.plan.client.Job.Get(ctx, jobID)
+ if err != nil {
+ return nil, fmt.Errorf("poll job %s: %w", jobID, err)
+ }
+
+ job := resp.Data
+
+ switch job.Status {
+ case "completed":
+ data := make(map[string]any)
+ if job.Result != nil {
+ if m, ok := job.Result.(map[string]any); ok {
+ data = m
+ }
+ }
+
+ changed, _ := data["changed"].(bool)
+ delete(data, "changed")
+
+ return &Result{Changed: changed, Data: data}, nil
+ case "failed":
+ errMsg := "job failed"
+ if job.Error != "" {
+ errMsg = job.Error
+ }
+
+ return nil, fmt.Errorf("job %s: %s", jobID, errMsg)
+ }
+ }
+ }
+}
+
+// levelize groups tasks into levels where all tasks in a level can
+// run concurrently (all dependencies are in earlier levels).
+func levelize(
+ tasks []*Task,
+) [][]*Task {
+ level := make(map[string]int, len(tasks))
+
+ var computeLevel func(t *Task) int
+ computeLevel = func(t *Task) int {
+ if l, ok := level[t.name]; ok {
+ return l
+ }
+
+ maxDep := -1
+
+ for _, dep := range t.deps {
+ depLevel := computeLevel(dep)
+ if depLevel > maxDep {
+ maxDep = depLevel
+ }
+ }
+
+ level[t.name] = maxDep + 1
+
+ return maxDep + 1
+ }
+
+ maxLevel := 0
+
+ for _, t := range tasks {
+ l := computeLevel(t)
+ if l > maxLevel {
+ maxLevel = l
+ }
+ }
+
+ levels := make([][]*Task, maxLevel+1)
+
+ for _, t := range tasks {
+ l := level[t.name]
+ levels[l] = append(levels[l], t)
+ }
+
+ return levels
+}
diff --git a/pkg/sdk/orchestrator/runner_broadcast_test.go b/pkg/sdk/orchestrator/runner_broadcast_test.go
new file mode 100644
index 00000000..b806db78
--- /dev/null
+++ b/pkg/sdk/orchestrator/runner_broadcast_test.go
@@ -0,0 +1,360 @@
+package orchestrator
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/suite"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+type RunnerBroadcastTestSuite struct {
+ suite.Suite
+}
+
+func TestRunnerBroadcastTestSuite(t *testing.T) {
+ suite.Run(t, new(RunnerBroadcastTestSuite))
+}
+
+func (s *RunnerBroadcastTestSuite) TestIsBroadcastTarget() {
+ tests := []struct {
+ name string
+ target string
+ want bool
+ }{
+ {
+ name: "all agents is broadcast",
+ target: "_all",
+ want: true,
+ },
+ {
+ name: "label selector is broadcast",
+ target: "role:web",
+ want: true,
+ },
+ {
+ name: "single agent is not broadcast",
+ target: "agent-001",
+ want: false,
+ },
+ {
+ name: "empty string is not broadcast",
+ target: "",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ got := IsBroadcastTarget(tt.target)
+ s.Equal(tt.want, got)
+ })
+ }
+}
+
+func (s *RunnerBroadcastTestSuite) TestExtractHostResults() {
+ tests := []struct {
+ name string
+ data map[string]any
+ want []HostResult
+ }{
+ {
+ name: "extracts host results from results array",
+ data: map[string]any{
+ "results": []any{
+ map[string]any{
+ "hostname": "host-1",
+ "changed": true,
+ "data": "something",
+ },
+ map[string]any{
+ "hostname": "host-2",
+ "changed": false,
+ "error": "connection refused",
+ },
+ },
+ },
+ want: []HostResult{
+ {
+ Hostname: "host-1",
+ Changed: true,
+ Data: map[string]any{
+ "hostname": "host-1",
+ "changed": true,
+ "data": "something",
+ },
+ },
+ {
+ Hostname: "host-2",
+ Changed: false,
+ Error: "connection refused",
+ Data: map[string]any{
+ "hostname": "host-2",
+ "changed": false,
+ "error": "connection refused",
+ },
+ },
+ },
+ },
+ {
+ name: "no results key returns nil",
+ data: map[string]any{
+ "other": "value",
+ },
+ want: nil,
+ },
+ {
+ name: "results not an array returns nil",
+ data: map[string]any{
+ "results": "not-an-array",
+ },
+ want: nil,
+ },
+ {
+ name: "empty results array returns empty slice",
+ data: map[string]any{
+ "results": []any{},
+ },
+ want: []HostResult{},
+ },
+ {
+ name: "non-map item in results array is skipped",
+ data: map[string]any{
+ "results": []any{
+ "not-a-map",
+ 42,
+ map[string]any{
+ "hostname": "host-1",
+ "changed": true,
+ },
+ },
+ },
+ want: []HostResult{
+ {
+ Hostname: "host-1",
+ Changed: true,
+ Data: map[string]any{
+ "hostname": "host-1",
+ "changed": true,
+ },
+ },
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ got := extractHostResults(tt.data)
+ s.Equal(tt.want, got)
+ })
+ }
+}
+
+func (s *RunnerBroadcastTestSuite) TestIsCommandOp() {
+ tests := []struct {
+ name string
+ operation string
+ want bool
+ }{
+ {
+ name: "command.exec.execute is a command op",
+ operation: "command.exec.execute",
+ want: true,
+ },
+ {
+ name: "command.shell.execute is a command op",
+ operation: "command.shell.execute",
+ want: true,
+ },
+ {
+ name: "node.hostname.get is not a command op",
+ operation: "node.hostname.get",
+ want: false,
+ },
+ {
+ name: "empty string is not a command op",
+ operation: "",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ got := isCommandOp(tt.operation)
+ s.Equal(tt.want, got)
+ })
+ }
+}
+
+// jobTestServer creates an httptest server that handles POST /job
+// and GET /job/{id} with the provided result payload.
+func jobTestServer(
+ jobResult map[string]any,
+) *httptest.Server {
+ const jobID = "11111111-1111-1111-1111-111111111111"
+
+ return httptest.NewServer(http.HandlerFunc(func(
+ w http.ResponseWriter,
+ r *http.Request,
+ ) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/job":
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusCreated)
+
+ resp := map[string]any{
+ "job_id": jobID,
+ "status": "created",
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+
+ case r.Method == http.MethodGet && r.URL.Path == fmt.Sprintf("/job/%s", jobID):
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ resp := map[string]any{
+ "id": jobID,
+ "status": "completed",
+ "result": jobResult,
+ }
+ _ = json.NewEncoder(w).Encode(resp)
+
+ default:
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+}
+
+func (s *RunnerBroadcastTestSuite) TestExecuteOpBroadcast() {
+ tests := []struct {
+ name string
+ jobResult map[string]any
+ wantHostResults int
+ wantHostname string
+ }{
+ {
+ name: "broadcast op extracts host results",
+ jobResult: map[string]any{
+ "results": []any{
+ map[string]any{
+ "hostname": "host-1",
+ "changed": true,
+ },
+ map[string]any{
+ "hostname": "host-2",
+ "changed": false,
+ },
+ },
+ },
+ wantHostResults: 2,
+ wantHostname: "host-1",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ origInterval := DefaultPollInterval
+ DefaultPollInterval = 10 * time.Millisecond
+
+ defer func() {
+ DefaultPollInterval = origInterval
+ }()
+
+ srv := jobTestServer(tt.jobResult)
+ defer srv.Close()
+
+ client := osapiclient.New(srv.URL, "test-token")
+ plan := NewPlan(client, OnError(StopAll))
+
+ plan.Task("broadcast-op", &Op{
+ Operation: "node.hostname.get",
+ Target: "_all",
+ })
+
+ report, err := plan.Run(context.Background())
+
+ s.Require().NoError(err)
+ s.Require().Len(report.Tasks, 1)
+ s.Len(
+ report.Tasks[0].HostResults,
+ tt.wantHostResults,
+ )
+ s.Equal(
+ tt.wantHostname,
+ report.Tasks[0].HostResults[0].Hostname,
+ )
+ })
+ }
+}
+
+func (s *RunnerBroadcastTestSuite) TestExecuteOpCommandNonZeroExit() {
+ tests := []struct {
+ name string
+ operation string
+ jobResult map[string]any
+ wantErr string
+ }{
+ {
+ name: "command exec with non-zero exit code fails",
+ operation: "command.exec.execute",
+ jobResult: map[string]any{
+ "exit_code": float64(1),
+ "stdout": "",
+ "stderr": "command not found",
+ },
+ wantErr: "command exited with code 1",
+ },
+ {
+ name: "command shell with non-zero exit code fails",
+ operation: "command.shell.execute",
+ jobResult: map[string]any{
+ "exit_code": float64(127),
+ "stdout": "",
+ "stderr": "not found",
+ },
+ wantErr: "command exited with code 127",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ origInterval := DefaultPollInterval
+ DefaultPollInterval = 10 * time.Millisecond
+
+ defer func() {
+ DefaultPollInterval = origInterval
+ }()
+
+ srv := jobTestServer(tt.jobResult)
+ defer srv.Close()
+
+ client := osapiclient.New(srv.URL, "test-token")
+ plan := NewPlan(client, OnError(Continue))
+
+ plan.Task("cmd-op", &Op{
+ Operation: tt.operation,
+ Target: "_any",
+ Params: map[string]any{"command": "false"},
+ })
+
+ report, err := plan.Run(context.Background())
+
+ // With Continue strategy, run() doesn't return
+ // the error, but the task result carries it.
+ _ = err
+ s.Require().Len(report.Tasks, 1)
+ s.Equal(StatusFailed, report.Tasks[0].Status)
+ s.Require().NotNil(report.Tasks[0].Error)
+ s.Contains(
+ report.Tasks[0].Error.Error(),
+ tt.wantErr,
+ )
+ })
+ }
+}
diff --git a/pkg/sdk/orchestrator/runner_test.go b/pkg/sdk/orchestrator/runner_test.go
new file mode 100644
index 00000000..b6a5b17f
--- /dev/null
+++ b/pkg/sdk/orchestrator/runner_test.go
@@ -0,0 +1,422 @@
+package orchestrator
+
+import (
+ "context"
+ "fmt"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+type RunnerTestSuite struct {
+ suite.Suite
+}
+
+func TestRunnerTestSuite(t *testing.T) {
+ suite.Run(t, new(RunnerTestSuite))
+}
+
+func (s *RunnerTestSuite) TestLevelize() {
+ tests := []struct {
+ name string
+ setup func() []*Task
+ wantLevels int
+ }{
+ {
+ name: "linear chain has 3 levels",
+ setup: func() []*Task {
+ a := NewTask("a", &Op{Operation: "noop"})
+ b := NewTask("b", &Op{Operation: "noop"})
+ c := NewTask("c", &Op{Operation: "noop"})
+ b.DependsOn(a)
+ c.DependsOn(b)
+
+ return []*Task{a, b, c}
+ },
+ wantLevels: 3,
+ },
+ {
+ name: "diamond has 3 levels",
+ setup: func() []*Task {
+ a := NewTask("a", &Op{Operation: "noop"})
+ b := NewTask("b", &Op{Operation: "noop"})
+ c := NewTask("c", &Op{Operation: "noop"})
+ d := NewTask("d", &Op{Operation: "noop"})
+ b.DependsOn(a)
+ c.DependsOn(a)
+ d.DependsOn(b, c)
+
+ return []*Task{a, b, c, d}
+ },
+ wantLevels: 3,
+ },
+ {
+ name: "independent tasks in 1 level",
+ setup: func() []*Task {
+ a := NewTask("a", &Op{Operation: "noop"})
+ b := NewTask("b", &Op{Operation: "noop"})
+
+ return []*Task{a, b}
+ },
+ wantLevels: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ tasks := tt.setup()
+ levels := levelize(tasks)
+ s.Len(levels, tt.wantLevels)
+ })
+ }
+}
+
+func (s *RunnerTestSuite) TestRunTaskStoresResultForAllPaths() {
+ tests := []struct {
+ name string
+ setup func() *Plan
+ taskName string
+ wantStatus Status
+ }{
+ {
+ name: "OnlyIfChanged skip stores StatusSkipped",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ // dep returns Changed=false, so child with
+ // OnlyIfChanged should be skipped.
+ dep := plan.TaskFunc("dep", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: false}, nil
+ })
+
+ child := plan.TaskFunc("child", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: true}, nil
+ })
+ child.DependsOn(dep)
+ child.OnlyIfChanged()
+
+ return plan
+ },
+ taskName: "child",
+ wantStatus: StatusSkipped,
+ },
+ {
+ name: "failed task stores StatusFailed",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ plan.TaskFunc("failing", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return nil, fmt.Errorf("deliberate error")
+ })
+
+ return plan
+ },
+ taskName: "failing",
+ wantStatus: StatusFailed,
+ },
+ {
+ name: "guard-false skip stores StatusSkipped",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ plan.TaskFunc("guarded", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: true}, nil
+ }).When(func(_ Results) bool {
+ return false
+ })
+
+ return plan
+ },
+ taskName: "guarded",
+ wantStatus: StatusSkipped,
+ },
+ {
+ name: "dependency-failed skip stores StatusSkipped",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ dep := plan.TaskFunc("dep", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return nil, fmt.Errorf("deliberate error")
+ })
+
+ child := plan.TaskFunc("child", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: true}, nil
+ })
+ child.DependsOn(dep)
+
+ return plan
+ },
+ taskName: "child",
+ wantStatus: StatusSkipped,
+ },
+ {
+ name: "successful changed task stores StatusChanged",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ plan.TaskFunc("ok", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: true}, nil
+ })
+
+ return plan
+ },
+ taskName: "ok",
+ wantStatus: StatusChanged,
+ },
+ {
+ name: "successful unchanged task stores StatusUnchanged",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(Continue))
+
+ plan.TaskFunc("ok", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: false}, nil
+ })
+
+ return plan
+ },
+ taskName: "ok",
+ wantStatus: StatusUnchanged,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := tt.setup()
+ runner := newRunner(plan)
+
+ _, err := runner.run(context.Background())
+ // Some plans produce errors (e.g. StopAll with a
+ // failing task); we don't assert on err here because
+ // we only care about the results map.
+ _ = err
+
+ result := runner.results.Get(tt.taskName)
+ s.NotNil(
+ result,
+ "results map should contain entry for %q",
+ tt.taskName,
+ )
+ s.Equal(
+ tt.wantStatus,
+ result.Status,
+ "result status for %q",
+ tt.taskName,
+ )
+ })
+ }
+}
+
+func (s *RunnerTestSuite) TestDownstreamGuardInspectsSkippedStatus() {
+ tests := []struct {
+ name string
+ setup func() (*Plan, *bool)
+ observerName string
+ wantGuardCalled bool
+ wantTaskStatus Status
+ }{
+ {
+ name: "guard can see guard-skipped task status",
+ setup: func() (*Plan, *bool) {
+ plan := NewPlan(nil, OnError(Continue))
+ guardCalled := false
+
+ // This task is skipped because its guard
+ // returns false.
+ guarded := plan.TaskFunc("guarded", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: true}, nil
+ })
+ guarded.When(func(_ Results) bool {
+ return false
+ })
+
+ // Observer depends on guarded so it runs in a
+ // later level. Its guard inspects the skipped
+ // task's status.
+ observer := plan.TaskFunc("observer", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{Changed: false}, nil
+ })
+ observer.DependsOn(guarded)
+ observer.When(func(r Results) bool {
+ guardCalled = true
+ res := r.Get("guarded")
+
+ return res != nil && res.Status == StatusSkipped
+ })
+
+ return plan, &guardCalled
+ },
+ observerName: "observer",
+ wantGuardCalled: true,
+ // Observer runs because the guard sees the skipped
+ // status and returns true.
+ wantTaskStatus: StatusUnchanged,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan, guardCalled := tt.setup()
+ runner := newRunner(plan)
+
+ _, err := runner.run(context.Background())
+ _ = err
+
+ s.Equal(
+ tt.wantGuardCalled,
+ *guardCalled,
+ "guard should have been called",
+ )
+
+ result := runner.results.Get(tt.observerName)
+ s.NotNil(
+ result,
+ "observer should have a result entry",
+ )
+ s.Equal(
+ tt.wantTaskStatus,
+ result.Status,
+ "observer task status",
+ )
+ })
+ }
+}
+
+func (s *RunnerTestSuite) TestTaskFuncWithResultsReceivesResults() {
+ tests := []struct {
+ name string
+ setup func() (*Plan, *string)
+ wantCapture string
+ }{
+ {
+ name: "receives upstream result data",
+ setup: func() (*Plan, *string) {
+ plan := NewPlan(nil, OnError(StopAll))
+ var captured string
+
+ a := plan.TaskFunc("a", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{
+ Changed: true,
+ Data: map[string]any{"hostname": "web-01"},
+ }, nil
+ })
+
+ b := plan.TaskFuncWithResults("b", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ results Results,
+ ) (*Result, error) {
+ r := results.Get("a")
+ if r != nil {
+ if h, ok := r.Data["hostname"].(string); ok {
+ captured = h
+ }
+ }
+
+ return &Result{Changed: false}, nil
+ })
+ b.DependsOn(a)
+
+ return plan, &captured
+ },
+ wantCapture: "web-01",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan, captured := tt.setup()
+
+ _, err := plan.Run(context.Background())
+
+ s.Require().NoError(err)
+ s.Equal(tt.wantCapture, *captured)
+ })
+ }
+}
+
+func (s *RunnerTestSuite) TestTaskResultCarriesData() {
+ tests := []struct {
+ name string
+ setup func() *Plan
+ taskName string
+ wantKey string
+ wantVal any
+ }{
+ {
+ name: "success result includes data",
+ setup: func() *Plan {
+ plan := NewPlan(nil, OnError(StopAll))
+
+ plan.TaskFunc("a", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*Result, error) {
+ return &Result{
+ Changed: true,
+ Data: map[string]any{"stdout": "hello"},
+ }, nil
+ })
+
+ return plan
+ },
+ taskName: "a",
+ wantKey: "stdout",
+ wantVal: "hello",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ plan := tt.setup()
+
+ report, err := plan.Run(context.Background())
+
+ s.Require().NoError(err)
+
+ var found bool
+ for _, tr := range report.Tasks {
+ if tr.Name == tt.taskName {
+ found = true
+ s.Equal(tt.wantVal, tr.Data[tt.wantKey])
+ }
+ }
+
+ s.True(found, "task %q should be in report", tt.taskName)
+ })
+ }
+}
diff --git a/pkg/sdk/orchestrator/task.go b/pkg/sdk/orchestrator/task.go
new file mode 100644
index 00000000..0791ace5
--- /dev/null
+++ b/pkg/sdk/orchestrator/task.go
@@ -0,0 +1,178 @@
+package orchestrator
+
+import (
+ "context"
+ "strings"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+)
+
+// Op represents a declarative SDK operation.
+type Op struct {
+ Operation string
+ Target string
+ Params map[string]any
+}
+
+// TaskFn is the signature for functional tasks. The client
+// parameter provides access to the OSAPI SDK for making API calls.
+type TaskFn func(
+ ctx context.Context,
+ client *osapiclient.Client,
+) (*Result, error)
+
+// TaskFnWithResults is like TaskFn but receives completed task results
+// for inter-task data access.
+type TaskFnWithResults func(
+ ctx context.Context,
+ client *osapiclient.Client,
+ results Results,
+) (*Result, error)
+
+// GuardFn is a predicate that determines if a task should run.
+type GuardFn func(results Results) bool
+
+// Task is a unit of work in an orchestration plan.
+type Task struct {
+ name string
+ op *Op
+ fn TaskFn
+ fnr TaskFnWithResults
+ deps []*Task
+ guard GuardFn
+ guardReason string
+ requiresChange bool
+ errorStrategy *ErrorStrategy
+}
+
+// NewTask creates a declarative task wrapping an SDK operation.
+func NewTask(
+ name string,
+ op *Op,
+) *Task {
+ return &Task{
+ name: name,
+ op: op,
+ }
+}
+
+// NewTaskFunc creates a functional task with custom logic.
+func NewTaskFunc(
+ name string,
+ fn TaskFn,
+) *Task {
+ return &Task{
+ name: name,
+ fn: fn,
+ }
+}
+
+// NewTaskFuncWithResults creates a functional task that receives
+// completed results from prior tasks.
+func NewTaskFuncWithResults(
+ name string,
+ fn TaskFnWithResults,
+) *Task {
+ return &Task{
+ name: name,
+ fnr: fn,
+ }
+}
+
+// Name returns the task name.
+func (t *Task) Name() string {
+ return t.name
+}
+
+// SetName changes the task name.
+func (t *Task) SetName(
+ name string,
+) {
+ t.name = name
+}
+
+// IsFunc returns true if this is a functional task.
+func (t *Task) IsFunc() bool {
+ return t.fn != nil || t.fnr != nil
+}
+
+// Operation returns the declarative operation, or nil for functional
+// tasks.
+func (t *Task) Operation() *Op {
+ return t.op
+}
+
+// Fn returns the task function, or nil for declarative tasks.
+func (t *Task) Fn() TaskFn {
+ return t.fn
+}
+
+// DependsOn sets this task's dependencies. Returns the task for
+// chaining.
+func (t *Task) DependsOn(
+ deps ...*Task,
+) *Task {
+ t.deps = append(t.deps, deps...)
+
+ return t
+}
+
+// Dependencies returns the task's dependencies.
+func (t *Task) Dependencies() []*Task {
+ return t.deps
+}
+
+// OnlyIfChanged marks this task to only run if at least one
+// dependency reported Changed=true.
+func (t *Task) OnlyIfChanged() {
+ t.requiresChange = true
+}
+
+// RequiresChange returns true if OnlyIfChanged was set.
+func (t *Task) RequiresChange() bool {
+ return t.requiresChange
+}
+
+// When sets a custom guard function that determines whether
+// this task should execute.
+func (t *Task) When(
+ fn GuardFn,
+) {
+ t.guard = fn
+}
+
+// WhenWithReason sets a guard with a custom skip reason shown when
+// the guard returns false.
+func (t *Task) WhenWithReason(
+ fn GuardFn,
+ reason string,
+) {
+ t.guard = fn
+ t.guardReason = reason
+}
+
+// Guard returns the guard function, or nil if none is set.
+func (t *Task) Guard() GuardFn {
+ return t.guard
+}
+
+// OnError sets a per-task error strategy override.
+func (t *Task) OnError(
+ strategy ErrorStrategy,
+) {
+ t.errorStrategy = &strategy
+}
+
+// ErrorStrategy returns the per-task error strategy, or nil to
+// use the plan default.
+func (t *Task) ErrorStrategy() *ErrorStrategy {
+ return t.errorStrategy
+}
+
+// IsBroadcastTarget returns true if the target addresses multiple
+// agents (broadcast or label selector).
+func IsBroadcastTarget(
+ target string,
+) bool {
+ return target == "_all" || strings.Contains(target, ":")
+}
diff --git a/pkg/sdk/orchestrator/task_public_test.go b/pkg/sdk/orchestrator/task_public_test.go
new file mode 100644
index 00000000..3e078280
--- /dev/null
+++ b/pkg/sdk/orchestrator/task_public_test.go
@@ -0,0 +1,191 @@
+package orchestrator_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+
+ osapiclient "github.com/retr0h/osapi/pkg/sdk/client"
+ "github.com/retr0h/osapi/pkg/sdk/orchestrator"
+)
+
+type TaskPublicTestSuite struct {
+ suite.Suite
+}
+
+func TestTaskPublicTestSuite(t *testing.T) {
+ suite.Run(t, new(TaskPublicTestSuite))
+}
+
+func (s *TaskPublicTestSuite) TestDependsOn() {
+ tests := []struct {
+ name string
+ setupDeps func(a, b, c *orchestrator.Task)
+ checkTask string
+ wantDepLen int
+ }{
+ {
+ name: "single dependency",
+ setupDeps: func(a, b, _ *orchestrator.Task) {
+ b.DependsOn(a)
+ },
+ checkTask: "b",
+ wantDepLen: 1,
+ },
+ {
+ name: "multiple dependencies",
+ setupDeps: func(a, b, c *orchestrator.Task) {
+ c.DependsOn(a, b)
+ },
+ checkTask: "c",
+ wantDepLen: 2,
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ a := orchestrator.NewTask("a", &orchestrator.Op{Operation: "noop"})
+ b := orchestrator.NewTask("b", &orchestrator.Op{Operation: "noop"})
+ c := orchestrator.NewTask("c", &orchestrator.Op{Operation: "noop"})
+ tt.setupDeps(a, b, c)
+
+ tasks := map[string]*orchestrator.Task{"a": a, "b": b, "c": c}
+ s.Len(tasks[tt.checkTask].Dependencies(), tt.wantDepLen)
+ })
+ }
+}
+
+func (s *TaskPublicTestSuite) TestOnlyIfChanged() {
+ task := orchestrator.NewTask("t", &orchestrator.Op{Operation: "noop"})
+ dep := orchestrator.NewTask("dep", &orchestrator.Op{Operation: "noop"})
+ task.DependsOn(dep).OnlyIfChanged()
+
+ s.True(task.RequiresChange())
+}
+
+func (s *TaskPublicTestSuite) TestWhen() {
+ task := orchestrator.NewTask("t", &orchestrator.Op{Operation: "noop"})
+ called := false
+ task.When(func(_ orchestrator.Results) bool {
+ called = true
+
+ return true
+ })
+
+ guard := task.Guard()
+ s.NotNil(guard)
+ s.True(guard(orchestrator.Results{}))
+ s.True(called)
+}
+
+func (s *TaskPublicTestSuite) TestTaskFunc() {
+ fn := func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ return &orchestrator.Result{Changed: true}, nil
+ }
+
+ task := orchestrator.NewTaskFunc("custom", fn)
+ s.Equal("custom", task.Name())
+ s.True(task.IsFunc())
+}
+
+func (s *TaskPublicTestSuite) TestSetName() {
+ tests := []struct {
+ name string
+ initial string
+ renamed string
+ wantName string
+ }{
+ {
+ name: "changes task name",
+ initial: "original",
+ renamed: "renamed",
+ wantName: "renamed",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ task := orchestrator.NewTask(
+ tt.initial,
+ &orchestrator.Op{Operation: "noop"},
+ )
+ task.SetName(tt.renamed)
+ s.Equal(tt.wantName, task.Name())
+ })
+ }
+}
+
+func (s *TaskPublicTestSuite) TestWhenWithReason() {
+ tests := []struct {
+ name string
+ guardResult bool
+ reason string
+ }{
+ {
+ name: "sets guard and reason when guard returns false",
+ guardResult: false,
+ reason: "host is unreachable",
+ },
+ {
+ name: "sets guard and reason when guard returns true",
+ guardResult: true,
+ reason: "custom reason",
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ task := orchestrator.NewTask(
+ "t",
+ &orchestrator.Op{Operation: "noop"},
+ )
+ task.WhenWithReason(func(_ orchestrator.Results) bool {
+ return tt.guardResult
+ }, tt.reason)
+
+ guard := task.Guard()
+ s.NotNil(guard)
+ s.Equal(tt.guardResult, guard(orchestrator.Results{}))
+ })
+ }
+}
+
+func (s *TaskPublicTestSuite) TestOnErrorOverride() {
+ task := orchestrator.NewTask("t", &orchestrator.Op{Operation: "noop"})
+ task.OnError(orchestrator.Continue)
+
+ s.NotNil(task.ErrorStrategy())
+ s.Equal("continue", task.ErrorStrategy().String())
+}
+
+func (s *TaskPublicTestSuite) TestOperation() {
+ op := &orchestrator.Op{Operation: "node.hostname.get", Target: "_any"}
+ task := orchestrator.NewTask("t", op)
+
+ s.Equal(op, task.Operation())
+
+ fnTask := orchestrator.NewTaskFunc("fn", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ return nil, nil
+ })
+ s.Nil(fnTask.Operation())
+}
+
+func (s *TaskPublicTestSuite) TestFn() {
+ task := orchestrator.NewTask("t", &orchestrator.Op{Operation: "noop"})
+ s.Nil(task.Fn())
+
+ fnTask := orchestrator.NewTaskFunc("fn", func(
+ _ context.Context,
+ _ *osapiclient.Client,
+ ) (*orchestrator.Result, error) {
+ return nil, nil
+ })
+ s.NotNil(fnTask.Fn())
+}
diff --git a/test/integration/agent_test.go b/test/integration/agent_test.go
index 82d89cd8..c9a3ba3c 100644
--- a/test/integration/agent_test.go
+++ b/test/integration/agent_test.go
@@ -109,6 +109,51 @@ func (s *AgentSmokeSuite) TestAgentGet() {
}
}
+func (s *AgentSmokeSuite) TestAgentDrainUndrain() {
+ skipWrite(s.T())
+
+ listOut, _, listCode := runCLI("client", "agent", "list", "--json")
+ s.Require().Equal(0, listCode)
+
+ var listResp struct {
+ Agents []struct {
+ Hostname string `json:"hostname"`
+ } `json:"agents"`
+ }
+ s.Require().NoError(parseJSON(listOut, &listResp))
+ s.Require().NotEmpty(listResp.Agents, "agent list must contain at least one entry")
+
+ hostname := listResp.Agents[0].Hostname
+
+ // Drain
+ drainOut, _, drainCode := runCLI(
+ "client", "agent", "drain",
+ "--hostname", hostname,
+ "--json",
+ )
+ s.Require().Equal(0, drainCode)
+
+ var drainResp struct {
+ Message string `json:"message"`
+ }
+ s.Require().NoError(parseJSON(drainOut, &drainResp))
+ s.NotEmpty(drainResp.Message)
+
+ // Undrain (always restore so later tests aren't affected)
+ undrainOut, _, undrainCode := runCLI(
+ "client", "agent", "undrain",
+ "--hostname", hostname,
+ "--json",
+ )
+ s.Require().Equal(0, undrainCode)
+
+ var undrainResp struct {
+ Message string `json:"message"`
+ }
+ s.Require().NoError(parseJSON(undrainOut, &undrainResp))
+ s.NotEmpty(undrainResp.Message)
+}
+
func TestAgentSmokeSuite(
t *testing.T,
) {
diff --git a/test/integration/file_test.go b/test/integration/file_test.go
new file mode 100644
index 00000000..ce72bb43
--- /dev/null
+++ b/test/integration/file_test.go
@@ -0,0 +1,126 @@
+//go:build integration
+
+// Copyright (c) 2026 John Dewey
+
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to
+// deal in the Software without restriction, including without limitation the
+// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
+// sell copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+// DEALINGS IN THE SOFTWARE.
+
+package integration_test
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/suite"
+)
+
+type FileSmokeSuite struct {
+ suite.Suite
+}
+
+func (s *FileSmokeSuite) TestFileList() {
+ tests := []struct {
+ name string
+ args []string
+ validateFunc func(stdout string, exitCode int)
+ }{
+ {
+ name: "returns file list with total",
+ args: []string{"client", "file", "list", "--json"},
+ validateFunc: func(
+ stdout string,
+ exitCode int,
+ ) {
+ s.Require().Equal(0, exitCode)
+
+ var result map[string]any
+ s.Require().NoError(parseJSON(stdout, &result))
+ s.Contains(result, "files")
+ s.Contains(result, "total")
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ s.Run(tt.name, func() {
+ stdout, _, exitCode := runCLI(tt.args...)
+ tt.validateFunc(stdout, exitCode)
+ })
+ }
+}
+
+func (s *FileSmokeSuite) TestFileUploadGetDelete() {
+ skipWrite(s.T())
+
+ filePath := writeTempFile(s.T(), "integration-test-content\n")
+
+ // Upload
+ uploadOut, _, uploadCode := runCLI(
+ "client", "file", "upload",
+ "--name", "test-int.conf",
+ "--file", filePath,
+ "--json",
+ )
+ s.Require().Equal(0, uploadCode)
+
+ var uploadResp struct {
+ Name string `json:"name"`
+ SHA256 string `json:"sha256"`
+ Size int `json:"size"`
+ Changed bool `json:"changed"`
+ }
+ s.Require().NoError(parseJSON(uploadOut, &uploadResp))
+ s.Equal("test-int.conf", uploadResp.Name)
+ s.NotEmpty(uploadResp.SHA256)
+ s.Greater(uploadResp.Size, 0)
+
+ // Get
+ getOut, _, getCode := runCLI(
+ "client", "file", "get",
+ "--name", "test-int.conf",
+ "--json",
+ )
+ s.Require().Equal(0, getCode)
+
+ var getResp struct {
+ Name string `json:"name"`
+ }
+ s.Require().NoError(parseJSON(getOut, &getResp))
+ s.Equal("test-int.conf", getResp.Name)
+
+ // Delete
+ deleteOut, _, deleteCode := runCLI(
+ "client", "file", "delete",
+ "--name", "test-int.conf",
+ "--json",
+ )
+ s.Require().Equal(0, deleteCode)
+
+ var deleteResp struct {
+ Name string `json:"name"`
+ Deleted bool `json:"deleted"`
+ }
+ s.Require().NoError(parseJSON(deleteOut, &deleteResp))
+ s.Equal("test-int.conf", deleteResp.Name)
+ s.True(deleteResp.Deleted)
+}
+
+func TestFileSmokeSuite(
+ t *testing.T,
+) {
+ suite.Run(t, new(FileSmokeSuite))
+}
diff --git a/test/integration/integration_test.go b/test/integration/integration_test.go
index d3f7dac2..ba7f94c3 100644
--- a/test/integration/integration_test.go
+++ b/test/integration/integration_test.go
@@ -236,6 +236,20 @@ func skipWrite(
}
}
+func writeTempFile(
+ t *testing.T,
+ content string,
+) string {
+ t.Helper()
+
+ path := filepath.Join(t.TempDir(), "upload.txt")
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatalf("write temp file: %v", err)
+ }
+
+ return path
+}
+
func writeJobFile(
t *testing.T,
data map[string]any,
diff --git a/test/integration/job_test.go b/test/integration/job_test.go
index 9b408e8e..a9743c05 100644
--- a/test/integration/job_test.go
+++ b/test/integration/job_test.go
@@ -109,36 +109,6 @@ func (s *JobSmokeSuite) TestJobGet() {
}
}
-func (s *JobSmokeSuite) TestJobStatus() {
- tests := []struct {
- name string
- args []string
- validateFunc func(stdout string, exitCode int)
- }{
- {
- name: "returns queue stats with total_jobs",
- args: []string{"client", "job", "status", "--json"},
- validateFunc: func(
- stdout string,
- exitCode int,
- ) {
- s.Require().Equal(0, exitCode)
-
- var result map[string]any
- s.Require().NoError(parseJSON(stdout, &result))
- s.Contains(result, "total_jobs")
- },
- },
- }
-
- for _, tt := range tests {
- s.Run(tt.name, func() {
- stdout, _, exitCode := runCLI(tt.args...)
- tt.validateFunc(stdout, exitCode)
- })
- }
-}
-
func (s *JobSmokeSuite) TestJobDelete() {
skipWrite(s.T())
diff --git a/test/integration/osapi.yaml b/test/integration/osapi.yaml
index cf60b36e..be4a7f47 100644
--- a/test/integration/osapi.yaml
+++ b/test/integration/osapi.yaml
@@ -59,6 +59,29 @@ nats:
storage: memory
replicas: 1
+ facts:
+ bucket: agent-facts
+ ttl: 5m
+ storage: memory
+ replicas: 1
+
+ state:
+ bucket: agent-state
+ storage: memory
+ replicas: 1
+
+ objects:
+ bucket: file-objects
+ max_bytes: 104857600
+ storage: memory
+ replicas: 1
+ max_chunk_size: 262144
+
+ file_state:
+ bucket: file-state
+ storage: memory
+ replicas: 1
+
dlq:
max_age: 7d
max_msgs: 1000