From 421caef023d6d3ddcdf1bcb3e5a0fbfdb4778207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:46:46 -0800 Subject: [PATCH 1/3] refactor(job): remove client job status TUI and API endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `client job status` command was a BubbleTea TUI that duplicated data already available via `client health status`. Remove the command, its `/job/status` API endpoint, SDK method, tests, and docs. This also drops the bubbletea dependency. The internal `GetQueueSummary` method is preserved as it powers health metrics and ListJobs status counts. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- cmd/client_job_status.go | 225 --------- .../docs/gen/api/get-queue-statistics.api.mdx | 458 ------------------ docs/docs/gen/api/sidebar.ts | 6 - .../sidebar/architecture/job-architecture.md | 22 +- docs/docs/sidebar/sdk/client/job.md | 1 - .../sidebar/usage/cli/client/job/status.md | 24 - examples/sdk/osapi/job.go | 8 - go.mod | 5 - go.sum | 13 - internal/api/gen/api.yaml | 50 -- internal/api/job/gen/api.yaml | 51 -- internal/api/job/gen/job.gen.go | 96 ---- internal/api/job/job_status.go | 47 -- internal/api/job/job_status_public_test.go | 289 ----------- pkg/sdk/osapi/gen/client.gen.go | 138 ------ pkg/sdk/osapi/job.go | 23 - pkg/sdk/osapi/job_public_test.go | 88 ---- pkg/sdk/osapi/job_types.go | 28 -- pkg/sdk/osapi/job_types_test.go | 52 -- 19 files changed, 2 insertions(+), 1622 deletions(-) delete mode 100644 cmd/client_job_status.go delete mode 100644 docs/docs/gen/api/get-queue-statistics.api.mdx delete mode 100644 docs/docs/sidebar/usage/cli/client/job/status.md delete mode 100644 internal/api/job/job_status.go delete mode 100644 internal/api/job/job_status_public_test.go 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/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/job.md b/docs/docs/sidebar/sdk/client/job.md index 6301ffc0..5f87fe01 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 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/examples/sdk/osapi/job.go b/examples/sdk/osapi/job.go index 517821df..65fb2863 100644 --- a/examples/sdk/osapi/job.go +++ b/examples/sdk/osapi/job.go @@ -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/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/pkg/sdk/osapi/gen/client.gen.go b/pkg/sdk/osapi/gen/client.gen.go index 3a78e45c..f7bada7a 100644 --- a/pkg/sdk/osapi/gen/client.gen.go +++ b/pkg/sdk/osapi/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/job.go b/pkg/sdk/osapi/job.go index 8965cc50..87c0c265 100644 --- a/pkg/sdk/osapi/job.go +++ b/pkg/sdk/osapi/job.go @@ -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/osapi/job_public_test.go index af3c5978..9adb8c8a 100644 --- a/pkg/sdk/osapi/job_public_test.go +++ b/pkg/sdk/osapi/job_public_test.go @@ -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 diff --git a/pkg/sdk/osapi/job_types.go b/pkg/sdk/osapi/job_types.go index 6d3ee5a8..b793f957 100644 --- a/pkg/sdk/osapi/job_types.go +++ b/pkg/sdk/osapi/job_types.go @@ -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/osapi/job_types_test.go index 430c99ab..c89eeccb 100644 --- a/pkg/sdk/osapi/job_types_test.go +++ b/pkg/sdk/osapi/job_types_test.go @@ -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)) } From 2640780bef5d15208ff7c1b1069e821b65971b1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 17:51:10 -0800 Subject: [PATCH 2/3] test(integration): remove job status integration test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TestJobStatus integration test exercised the removed `client job status` CLI command and `/job/status` endpoint. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- test/integration/job_test.go | 30 ------------------------------ 1 file changed, 30 deletions(-) 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()) From fde3a2ed0ebe09ca648f30c3274fda4abf5c0ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Sat, 7 Mar 2026 21:52:12 -0800 Subject: [PATCH 3/3] feat(sdk): move orchestrator into monorepo and reorganize SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the orchestrator package from osapi-sdk into the monorepo at pkg/sdk/orchestrator/. Rename pkg/sdk/osapi/ to pkg/sdk/client/ to match Go naming conventions. Reorganize SDK docs and examples with proper Docusaurus sidebar categories for operations and features. Add comprehensive orchestrator documentation covering all public APIs: task functions, only-if-changed, failure recovery, guards with WhenWithReason, and plan introspection (Explain/Levels/Validate). 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- CLAUDE.md | 18 +- README.md | 4 +- cmd/client.go | 8 +- cmd/client_agent_get.go | 4 +- cmd/client_audit_export.go | 4 +- cmd/client_file_upload.go | 6 +- cmd/client_health_status.go | 4 +- cmd/client_job_list.go | 8 +- cmd/client_job_run.go | 4 +- cmd/client_node_command_exec.go | 6 +- cmd/client_node_command_shell.go | 4 +- cmd/client_node_file_deploy.go | 4 +- cmd/client_node_status_get.go | 6 +- docs/docs/sidebar/sdk/client/agent.md | 2 +- docs/docs/sidebar/sdk/client/audit.md | 2 +- docs/docs/sidebar/sdk/client/client.md | 6 +- docs/docs/sidebar/sdk/client/file.md | 8 +- docs/docs/sidebar/sdk/client/health.md | 2 +- docs/docs/sidebar/sdk/client/job.md | 4 +- docs/docs/sidebar/sdk/client/metrics.md | 2 +- docs/docs/sidebar/sdk/client/node.md | 12 +- .../sdk/orchestrator/features/_category_.json | 4 + .../sdk/orchestrator/features/basic.md | 38 + .../sdk/orchestrator/features/broadcast.md | 50 + .../orchestrator/features/error-strategy.md | 56 + .../features/file-deploy-workflow.md | 51 + .../sdk/orchestrator/features/guards.md | 64 + .../sdk/orchestrator/features/hooks.md | 43 + .../orchestrator/features/introspection.md | 74 + .../orchestrator/features/only-if-changed.md | 53 + .../orchestrator/features/only-if-failed.md | 67 + .../sdk/orchestrator/features/parallel.md | 39 + .../orchestrator/features/result-decode.md | 53 + .../sdk/orchestrator/features/retry.md | 35 + .../sdk/orchestrator/features/task-func.md | 82 + .../orchestrator/operations/_category_.json | 4 + .../orchestrator/operations/command-exec.md | 47 + .../orchestrator/operations/command-shell.md | 46 + .../orchestrator/operations/file-deploy.md | 76 + .../orchestrator/operations/file-status.md | 45 + .../orchestrator/operations/file-upload.md | 46 + .../operations/network-dns-get.md | 44 + .../operations/network-dns-update.md | 48 + .../orchestrator/operations/network-ping.md | 44 + .../sdk/orchestrator/operations/node-disk.md | 39 + .../orchestrator/operations/node-hostname.md | 39 + .../sdk/orchestrator/operations/node-load.md | 39 + .../orchestrator/operations/node-memory.md | 39 + .../orchestrator/operations/node-status.md | 40 + .../orchestrator/operations/node-uptime.md | 39 + .../sidebar/sdk/orchestrator/orchestrator.md | 193 +++ docs/docs/sidebar/sdk/sdk.md | 5 + docs/docusaurus.config.ts | 90 +- examples/sdk/{osapi => client}/agent.go | 4 +- examples/sdk/{osapi => client}/audit.go | 4 +- examples/sdk/{osapi => client}/command.go | 8 +- examples/sdk/{osapi => client}/file.go | 8 +- examples/sdk/{osapi => client}/go.mod | 2 +- examples/sdk/{osapi => client}/go.sum | 0 examples/sdk/{osapi => client}/health.go | 4 +- examples/sdk/{osapi => client}/job.go | 6 +- examples/sdk/{osapi => client}/metrics.go | 4 +- examples/sdk/{osapi => client}/network.go | 4 +- examples/sdk/{osapi => client}/node.go | 4 +- examples/sdk/orchestrator/features/basic.go | 91 ++ .../sdk/orchestrator/features/broadcast.go | 106 ++ .../orchestrator/features/error-strategy.go | 95 ++ .../features/file-deploy-workflow.go | 128 ++ examples/sdk/orchestrator/features/go.mod | 20 + examples/sdk/orchestrator/features/go.sum | 39 + examples/sdk/orchestrator/features/guards.go | 109 ++ examples/sdk/orchestrator/features/hooks.go | 143 ++ .../orchestrator/features/only-if-changed.go | 89 ++ .../orchestrator/features/only-if-failed.go | 107 ++ .../sdk/orchestrator/features/parallel.go | 112 ++ .../orchestrator/features/result-decode.go | 79 + examples/sdk/orchestrator/features/retry.go | 77 + .../features/task-func-results.go | 108 ++ .../sdk/orchestrator/features/task-func.go | 92 ++ .../orchestrator/operations/command-exec.go | 83 + .../orchestrator/operations/command-shell.go | 83 + .../orchestrator/operations/file-deploy.go | 86 ++ .../orchestrator/operations/file-status.go | 83 + .../orchestrator/operations/file-upload.go | 89 ++ examples/sdk/orchestrator/operations/go.mod | 20 + examples/sdk/orchestrator/operations/go.sum | 39 + .../operations/network-dns-get.go | 83 + .../operations/network-dns-update.go | 84 ++ .../orchestrator/operations/network-ping.go | 84 ++ .../sdk/orchestrator/operations/node-disk.go | 80 + .../orchestrator/operations/node-hostname.go | 80 + .../sdk/orchestrator/operations/node-load.go | 80 + .../orchestrator/operations/node-memory.go | 80 + .../orchestrator/operations/node-status.go | 80 + .../orchestrator/operations/node-uptime.go | 80 + internal/audit/export/export_public_test.go | 36 +- internal/audit/export/file.go | 4 +- internal/audit/export/file_public_test.go | 16 +- internal/audit/export/file_test.go | 4 +- internal/audit/export/types.go | 6 +- internal/cli/ui.go | 6 +- internal/cli/ui_public_test.go | 46 +- pkg/sdk/{osapi => client}/agent.go | 4 +- .../{osapi => client}/agent_public_test.go | 84 +- pkg/sdk/{osapi => client}/agent_types.go | 4 +- pkg/sdk/{osapi => client}/agent_types_test.go | 4 +- pkg/sdk/{osapi => client}/audit.go | 4 +- .../{osapi => client}/audit_public_test.go | 60 +- pkg/sdk/{osapi => client}/audit_types.go | 4 +- pkg/sdk/{osapi => client}/audit_types_test.go | 4 +- pkg/sdk/{osapi => client}/errors.go | 2 +- .../{osapi => client}/errors_public_test.go | 102 +- pkg/sdk/{osapi => client}/file.go | 4 +- pkg/sdk/{osapi => client}/file_public_test.go | 126 +- pkg/sdk/{osapi => client}/file_types.go | 4 +- pkg/sdk/{osapi => client}/file_types_test.go | 4 +- pkg/sdk/{osapi => client}/gen/cfg.yaml | 0 pkg/sdk/{osapi => client}/gen/client.gen.go | 0 pkg/sdk/{osapi => client}/gen/generate.go | 0 pkg/sdk/{osapi => client}/health.go | 4 +- .../{osapi => client}/health_public_test.go | 74 +- pkg/sdk/{osapi => client}/health_types.go | 4 +- .../{osapi => client}/health_types_test.go | 4 +- pkg/sdk/{osapi => client}/job.go | 4 +- pkg/sdk/{osapi => client}/job_public_test.go | 102 +- pkg/sdk/{osapi => client}/job_types.go | 4 +- pkg/sdk/{osapi => client}/job_types_test.go | 4 +- pkg/sdk/{osapi => client}/metrics.go | 4 +- .../{osapi => client}/metrics_public_test.go | 8 +- pkg/sdk/{osapi => client}/metrics_test.go | 2 +- pkg/sdk/{osapi => client}/node.go | 4 +- pkg/sdk/{osapi => client}/node_public_test.go | 314 ++-- pkg/sdk/{osapi => client}/node_types.go | 4 +- pkg/sdk/{osapi => client}/node_types_test.go | 4 +- pkg/sdk/{osapi => client}/osapi.go | 10 +- .../{osapi => client}/osapi_public_test.go | 24 +- pkg/sdk/{osapi => client}/response.go | 4 +- .../{osapi => client}/response_public_test.go | 20 +- pkg/sdk/{osapi => client}/response_test.go | 4 +- pkg/sdk/{osapi => client}/transport.go | 2 +- pkg/sdk/{osapi => client}/transport_test.go | 2 +- pkg/sdk/{osapi => client}/types.go | 2 +- pkg/sdk/orchestrator/options.go | 79 + pkg/sdk/orchestrator/options_public_test.go | 137 ++ pkg/sdk/orchestrator/plan.go | 226 +++ pkg/sdk/orchestrator/plan_public_test.go | 1343 +++++++++++++++++ pkg/sdk/orchestrator/result.go | 120 ++ pkg/sdk/orchestrator/result_public_test.go | 251 +++ pkg/sdk/orchestrator/runner.go | 598 ++++++++ pkg/sdk/orchestrator/runner_broadcast_test.go | 360 +++++ pkg/sdk/orchestrator/runner_test.go | 422 ++++++ pkg/sdk/orchestrator/task.go | 178 +++ pkg/sdk/orchestrator/task_public_test.go | 191 +++ test/integration/agent_test.go | 45 + test/integration/file_test.go | 126 ++ test/integration/integration_test.go | 14 + test/integration/osapi.yaml | 23 + 157 files changed, 9002 insertions(+), 654 deletions(-) create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/_category_.json create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/basic.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/broadcast.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/error-strategy.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/guards.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/hooks.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/introspection.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/only-if-failed.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/parallel.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/result-decode.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/retry.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/features/task-func.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/_category_.json create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/file-status.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-load.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-status.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md create mode 100644 docs/docs/sidebar/sdk/orchestrator/orchestrator.md rename examples/sdk/{osapi => client}/agent.go (97%) rename examples/sdk/{osapi => client}/audit.go (96%) rename examples/sdk/{osapi => client}/command.go (92%) rename examples/sdk/{osapi => client}/file.go (95%) rename examples/sdk/{osapi => client}/go.mod (92%) rename examples/sdk/{osapi => client}/go.sum (100%) rename examples/sdk/{osapi => client}/health.go (96%) rename examples/sdk/{osapi => client}/job.go (94%) rename examples/sdk/{osapi => client}/metrics.go (95%) rename examples/sdk/{osapi => client}/network.go (96%) rename examples/sdk/{osapi => client}/node.go (97%) create mode 100644 examples/sdk/orchestrator/features/basic.go create mode 100644 examples/sdk/orchestrator/features/broadcast.go create mode 100644 examples/sdk/orchestrator/features/error-strategy.go create mode 100644 examples/sdk/orchestrator/features/file-deploy-workflow.go create mode 100644 examples/sdk/orchestrator/features/go.mod create mode 100644 examples/sdk/orchestrator/features/go.sum create mode 100644 examples/sdk/orchestrator/features/guards.go create mode 100644 examples/sdk/orchestrator/features/hooks.go create mode 100644 examples/sdk/orchestrator/features/only-if-changed.go create mode 100644 examples/sdk/orchestrator/features/only-if-failed.go create mode 100644 examples/sdk/orchestrator/features/parallel.go create mode 100644 examples/sdk/orchestrator/features/result-decode.go create mode 100644 examples/sdk/orchestrator/features/retry.go create mode 100644 examples/sdk/orchestrator/features/task-func-results.go create mode 100644 examples/sdk/orchestrator/features/task-func.go create mode 100644 examples/sdk/orchestrator/operations/command-exec.go create mode 100644 examples/sdk/orchestrator/operations/command-shell.go create mode 100644 examples/sdk/orchestrator/operations/file-deploy.go create mode 100644 examples/sdk/orchestrator/operations/file-status.go create mode 100644 examples/sdk/orchestrator/operations/file-upload.go create mode 100644 examples/sdk/orchestrator/operations/go.mod create mode 100644 examples/sdk/orchestrator/operations/go.sum create mode 100644 examples/sdk/orchestrator/operations/network-dns-get.go create mode 100644 examples/sdk/orchestrator/operations/network-dns-update.go create mode 100644 examples/sdk/orchestrator/operations/network-ping.go create mode 100644 examples/sdk/orchestrator/operations/node-disk.go create mode 100644 examples/sdk/orchestrator/operations/node-hostname.go create mode 100644 examples/sdk/orchestrator/operations/node-load.go create mode 100644 examples/sdk/orchestrator/operations/node-memory.go create mode 100644 examples/sdk/orchestrator/operations/node-status.go create mode 100644 examples/sdk/orchestrator/operations/node-uptime.go rename pkg/sdk/{osapi => client}/agent.go (98%) rename pkg/sdk/{osapi => client}/agent_public_test.go (81%) rename pkg/sdk/{osapi => client}/agent_types.go (98%) rename pkg/sdk/{osapi => client}/agent_types_test.go (99%) rename pkg/sdk/{osapi => client}/audit.go (98%) rename pkg/sdk/{osapi => client}/audit_public_test.go (84%) rename pkg/sdk/{osapi => client}/audit_types.go (97%) rename pkg/sdk/{osapi => client}/audit_types_test.go (98%) rename pkg/sdk/{osapi => client}/errors.go (99%) rename pkg/sdk/{osapi => client}/errors_public_test.go (79%) rename pkg/sdk/{osapi => client}/file.go (99%) rename pkg/sdk/{osapi => client}/file_public_test.go (84%) rename pkg/sdk/{osapi => client}/file_types.go (98%) rename pkg/sdk/{osapi => client}/file_types_test.go (99%) rename pkg/sdk/{osapi => client}/gen/cfg.yaml (100%) rename pkg/sdk/{osapi => client}/gen/client.gen.go (100%) rename pkg/sdk/{osapi => client}/gen/generate.go (100%) rename pkg/sdk/{osapi => client}/health.go (98%) rename pkg/sdk/{osapi => client}/health_public_test.go (82%) rename pkg/sdk/{osapi => client}/health_types.go (98%) rename pkg/sdk/{osapi => client}/health_types_test.go (99%) rename pkg/sdk/{osapi => client}/job.go (98%) rename pkg/sdk/{osapi => client}/job_public_test.go (84%) rename pkg/sdk/{osapi => client}/job_types.go (98%) rename pkg/sdk/{osapi => client}/job_types_test.go (99%) rename pkg/sdk/{osapi => client}/metrics.go (97%) rename pkg/sdk/{osapi => client}/metrics_public_test.go (96%) rename pkg/sdk/{osapi => client}/metrics_test.go (99%) rename pkg/sdk/{osapi => client}/node.go (99%) rename pkg/sdk/{osapi => client}/node_public_test.go (79%) rename pkg/sdk/{osapi => client}/node_types.go (99%) rename pkg/sdk/{osapi => client}/node_types_test.go (99%) rename pkg/sdk/{osapi => client}/osapi.go (93%) rename pkg/sdk/{osapi => client}/osapi_public_test.go (83%) rename pkg/sdk/{osapi => client}/response.go (97%) rename pkg/sdk/{osapi => client}/response_public_test.go (83%) rename pkg/sdk/{osapi => client}/response_test.go (98%) rename pkg/sdk/{osapi => client}/transport.go (99%) rename pkg/sdk/{osapi => client}/transport_test.go (99%) rename pkg/sdk/{osapi => client}/types.go (98%) create mode 100644 pkg/sdk/orchestrator/options.go create mode 100644 pkg/sdk/orchestrator/options_public_test.go create mode 100644 pkg/sdk/orchestrator/plan.go create mode 100644 pkg/sdk/orchestrator/plan_public_test.go create mode 100644 pkg/sdk/orchestrator/result.go create mode 100644 pkg/sdk/orchestrator/result_public_test.go create mode 100644 pkg/sdk/orchestrator/runner.go create mode 100644 pkg/sdk/orchestrator/runner_broadcast_test.go create mode 100644 pkg/sdk/orchestrator/runner_test.go create mode 100644 pkg/sdk/orchestrator/task.go create mode 100644 pkg/sdk/orchestrator/task_public_test.go create mode 100644 test/integration/file_test.go 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_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/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 5f87fe01..2cea88fe 100644 --- a/docs/docs/sidebar/sdk/client/job.md +++ b/docs/docs/sidebar/sdk/client/job.md @@ -26,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, }) @@ -38,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/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 94% rename from examples/sdk/osapi/job.go rename to examples/sdk/client/job.go index 65fb2863..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) } 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/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 100% rename from pkg/sdk/osapi/gen/client.gen.go rename to pkg/sdk/client/gen/client.gen.go 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 98% rename from pkg/sdk/osapi/job.go rename to pkg/sdk/client/job.go index 87c0c265..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. diff --git a/pkg/sdk/osapi/job_public_test.go b/pkg/sdk/client/job_public_test.go similarity index 84% rename from pkg/sdk/osapi/job_public_test.go rename to pkg/sdk/client/job_public_test.go index 9adb8c8a..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) @@ -465,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", @@ -480,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) @@ -500,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) }, @@ -518,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") @@ -529,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") @@ -544,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) }, @@ -560,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") }, @@ -586,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 98% rename from pkg/sdk/osapi/job_types.go rename to pkg/sdk/client/job_types.go index b793f957..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. diff --git a/pkg/sdk/osapi/job_types_test.go b/pkg/sdk/client/job_types_test.go similarity index 99% rename from pkg/sdk/osapi/job_types_test.go rename to pkg/sdk/client/job_types_test.go index c89eeccb..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 { 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/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