diff --git a/docs/docs/sidebar/sdk/orchestrator/features/basic.md b/docs/docs/sidebar/sdk/orchestrator/features/basic.md index be94f3d8..f7182380 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/basic.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/basic.md @@ -9,27 +9,48 @@ Create a plan, add tasks with dependencies, and run them in order. ## Usage ```go -client := client.New(url, token) -plan := orchestrator.NewPlan(client) +c := client.New(url, token) +plan := orchestrator.NewPlan(c) health := plan.TaskFunc("check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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 := plan.TaskFunc("get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) 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. +`TaskFunc` creates a task with custom Go logic that calls the SDK client +directly. `DependsOn` declares ordering. ## Example diff --git a/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md b/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md index 4328e1e5..1448228e 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/broadcast.md @@ -21,10 +21,28 @@ 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", -}) +getAll := plan.TaskFunc("get-hostname-all", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_all") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) // Access per-host results via TaskFuncWithResults. printHosts := plan.TaskFuncWithResults("print-hosts", @@ -35,7 +53,8 @@ printHosts := plan.TaskFuncWithResults("print-hosts", ) (*orchestrator.Result, error) { r := results.Get("get-hostname-all") for _, hr := range r.HostResults { - fmt.Printf(" %s changed=%v\n", hr.Hostname, hr.Changed) + fmt.Printf(" %s changed=%v\n", + hr.Hostname, hr.Changed) } return &orchestrator.Result{Changed: false}, nil }, diff --git a/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md b/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md index 816457ca..71c902d5 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/file-deploy-workflow.md @@ -12,35 +12,69 @@ Store, deploy it to agents with template rendering, then verify status. ```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()) + 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 + 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 := plan.TaskFunc("deploy-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileDeploy(ctx, client.FileDeployOpts{ + Target: "_all", + ObjectName: "app.conf.tmpl", + Path: "/etc/app/config.yaml", + ContentType: "template", + Vars: map[string]any{"port": 8080}, + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) 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 := plan.TaskFunc("verify-status", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileStatus( + ctx, "_all", "/etc/app/config.yaml", + ) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil + }, +) verify.DependsOn(deploy) ``` diff --git a/docs/docs/sidebar/sdk/orchestrator/features/introspection.md b/docs/docs/sidebar/sdk/orchestrator/features/introspection.md index 9b9d77e9..e7149403 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/introspection.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/introspection.md @@ -15,10 +15,28 @@ showing levels, parallelism, dependencies, and guards. plan := orchestrator.NewPlan(client) health := plan.TaskFunc("check-health", healthFn) -hostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", -}) +hostname := plan.TaskFunc("get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) hostname.DependsOn(health) fmt.Println(plan.Explain()) @@ -33,12 +51,11 @@ Level 0: check-health [fn] Level 1: - get-hostname [op] <- check-health + get-hostname [fn] <- 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`). +Tasks are annotated with their type (`fn` for functional tasks), dependency +edges (`<-`), and any active guards (`only-if-changed`, `when`). ## Levels diff --git a/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md b/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md index d74ce303..ae84cc89 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/only-if-changed.md @@ -13,18 +13,34 @@ 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", +deploy := plan.TaskFunc("deploy-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileDeploy(ctx, client.FileDeployOpts{ + Target: "_any", + ObjectName: "app.conf", + Path: "/etc/app/app.conf", + ContentType: "text/plain", + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) restart := plan.TaskFunc("restart-service", - func(_ context.Context, _ *client.Client) (*orchestrator.Result, error) { + func( + _ context.Context, + _ *client.Client, + ) (*orchestrator.Result, error) { fmt.Println("Restarting service...") return &orchestrator.Result{Changed: true}, nil }, diff --git a/docs/docs/sidebar/sdk/orchestrator/features/parallel.md b/docs/docs/sidebar/sdk/orchestrator/features/parallel.md index 5e834f73..2b5465d6 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/parallel.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/parallel.md @@ -11,7 +11,10 @@ don't depend on each other are automatically parallelized. ```go health := plan.TaskFunc("check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { _, err := c.Health.Liveness(ctx) return &orchestrator.Result{Changed: false}, err }, @@ -19,17 +22,77 @@ health := plan.TaskFunc("check-health", // 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) -} +getHostname := plan.TaskFunc("get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) +getHostname.DependsOn(health) + +getDisk := plan.TaskFunc("get-disk", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Disk(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.DiskResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) +getDisk.DependsOn(health) + +getMemory := plan.TaskFunc("get-memory", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Memory(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.MemoryResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) +getMemory.DependsOn(health) ``` ## Example diff --git a/docs/docs/sidebar/sdk/orchestrator/features/retry.md b/docs/docs/sidebar/sdk/orchestrator/features/retry.md index d645d051..4d10db84 100644 --- a/docs/docs/sidebar/sdk/orchestrator/features/retry.md +++ b/docs/docs/sidebar/sdk/orchestrator/features/retry.md @@ -14,10 +14,28 @@ immediate or use exponential backoff to avoid overwhelming a recovering service. Retry up to 3 times with no delay between attempts: ```go -getLoad := plan.Task("get-load", &orchestrator.Op{ - Operation: "node.load.get", - Target: "_any", -}) +getLoad := plan.TaskFunc("get-load", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Load(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.LoadResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) getLoad.OnError(orchestrator.Retry(3)) ``` @@ -40,7 +58,10 @@ Set as the default strategy for all tasks in the plan: ```go plan := orchestrator.NewPlan(client, orchestrator.OnError(orchestrator.Retry(3, - orchestrator.WithRetryBackoff(500*time.Millisecond, 10*time.Second), + orchestrator.WithRetryBackoff( + 500*time.Millisecond, + 10*time.Second, + ), )), ) ``` @@ -51,7 +72,11 @@ Use the `OnRetry` hook to observe retry attempts: ```go hooks := orchestrator.Hooks{ - OnRetry: func(task *orchestrator.Task, attempt int, err error) { + OnRetry: func( + task *orchestrator.Task, + attempt int, + err error, + ) { fmt.Printf("[retry] %s attempt=%d error=%q\n", task.Name(), attempt, err) }, diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md b/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md index bd7f486e..4f3280a5 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/command-exec.md @@ -9,14 +9,32 @@ 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"}, +task := plan.TaskFunc("install-nginx", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Exec(ctx, client.ExecRequest{ + Target: "_all", + Command: "apt", + Args: []string{"install", "-y", "nginx"}, + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.CommandResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md b/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md index 398e5d9e..52080e1e 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/command-shell.md @@ -10,13 +10,31 @@ Execute a shell command string on the target node. The command is passed to ## 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}'", +task := plan.TaskFunc("check-disk-space", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Shell(ctx, client.ShellRequest{ + Target: "_any", + Command: "df -h / | tail -1 | awk '{print $5}'", + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.CommandResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md index 2d98cc6d..dc334703 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-deploy.md @@ -10,36 +10,62 @@ 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", +task := plan.TaskFunc("deploy-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileDeploy(ctx, client.FileDeployOpts{ + Target: "_all", + ObjectName: "nginx.conf", + Path: "/etc/nginx/nginx.conf", + ContentType: "raw", + Mode: "0644", + Owner: "root", + Group: "root", + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) ``` ### 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, - }, +task := plan.TaskFunc("deploy-template", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileDeploy(ctx, client.FileDeployOpts{ + Target: "web-01", + ObjectName: "app.conf.tmpl", + Path: "/etc/app/config.yaml", + ContentType: "template", + Vars: map[string]any{ + "port": 8080, + "debug": false, + }, + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md index f8f5c807..66a9ccf3 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-status.md @@ -10,13 +10,27 @@ 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", +task := plan.TaskFunc("check-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileStatus( + ctx, + "web-01", + "/etc/nginx/nginx.conf", + ) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md b/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md index ec3edc5f..d44559f6 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/file-upload.md @@ -10,21 +10,36 @@ 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, +task := plan.TaskFunc("upload-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.File.Upload( + ctx, + "nginx.conf", + "application/octet-stream", + bytes.NewReader(configBytes), + ) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + Changed: true, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, -}) +) ``` ## Parameters -| Param | Type | Required | Description | -| --------- | ------ | -------- | ------------------------------- | -| `name` | string | Yes | Object name in the Object Store | -| `content` | []byte | Yes | File content to upload | +| Param | Type | Required | Description | +| -------------- | --------- | -------- | ------------------------------- | +| `name` | string | Yes | Object name in the Object Store | +| `content_type` | string | Yes | MIME type of the content | +| `content` | io.Reader | Yes | File content to upload | ## Target diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md index 117a9de3..392abfeb 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-get.md @@ -9,13 +9,28 @@ 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", +task := plan.TaskFunc("get-dns", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.GetDNS(ctx, "_any", "eth0") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.DNSConfig) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md index e3f24291..ff25ca32 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-dns-update.md @@ -9,14 +9,34 @@ 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"}, +task := plan.TaskFunc("update-dns", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.UpdateDNS( + ctx, + "_all", + "eth0", + []string{"8.8.8.8", "8.8.4.4"}, + nil, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.DNSUpdateResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md b/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md index 5cb9bf82..be360cad 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/network-ping.md @@ -9,13 +9,28 @@ 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", +task := plan.TaskFunc("ping-gateway", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Ping(ctx, "_any", "192.168.1.1") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.PingResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, -}) +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md index e0932323..95f01745 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-disk.md @@ -9,10 +9,28 @@ Get disk usage statistics for all mounted filesystems. ## Usage ```go -task := plan.Task("get-disk", &orchestrator.Op{ - Operation: "node.disk.get", - Target: "_any", -}) +task := plan.TaskFunc("get-disk", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Disk(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.DiskResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md index 871d950e..4978a134 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-hostname.md @@ -9,10 +9,28 @@ Get the system hostname and agent labels. ## Usage ```go -task := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", -}) +task := plan.TaskFunc("get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md index 70e12a2c..d222cfdc 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-load.md @@ -9,10 +9,28 @@ 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", -}) +task := plan.TaskFunc("get-load", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Load(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.LoadResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md index 1551c26e..450cbb72 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-memory.md @@ -9,10 +9,28 @@ Get memory statistics including total, available, used, and swap. ## Usage ```go -task := plan.Task("get-memory", &orchestrator.Op{ - Operation: "node.memory.get", - Target: "_any", -}) +task := plan.TaskFunc("get-memory", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Memory(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.MemoryResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md index e578cd6d..1a979e22 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-status.md @@ -10,10 +10,28 @@ usage, memory statistics, and load averages. ## Usage ```go -task := plan.Task("get-status", &orchestrator.Op{ - Operation: "node.status.get", - Target: "web-01", -}) +task := plan.TaskFunc("get-status", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Status(ctx, "web-01") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.NodeStatus) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md b/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md index a745de53..2adbbde1 100644 --- a/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md +++ b/docs/docs/sidebar/sdk/orchestrator/operations/node-uptime.md @@ -9,10 +9,28 @@ Get system uptime information. ## Usage ```go -task := plan.Task("get-uptime", &orchestrator.Op{ - Operation: "node.uptime.get", - Target: "_any", -}) +task := plan.TaskFunc("get-uptime", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Uptime(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.UptimeResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) ``` ## Parameters diff --git a/docs/docs/sidebar/sdk/orchestrator/orchestrator.md b/docs/docs/sidebar/sdk/orchestrator/orchestrator.md index 9111340c..ca72a359 100644 --- a/docs/docs/sidebar/sdk/orchestrator/orchestrator.md +++ b/docs/docs/sidebar/sdk/orchestrator/orchestrator.md @@ -16,20 +16,41 @@ import ( "github.com/retr0h/osapi/pkg/sdk/client" ) -client := client.New("http://localhost:8080", "your-jwt-token") -plan := orchestrator.NewPlan(client) +c := client.New("http://localhost:8080", "your-jwt-token") +plan := orchestrator.NewPlan(c) health := plan.TaskFunc("check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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 := plan.TaskFunc("get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, +) hostname.DependsOn(health) report, err := plan.Run(context.Background()) @@ -151,6 +172,48 @@ Per-host data for broadcast operations (targeting `_all` or label selectors): | `Error` | `string` | Error message; empty on success | | `Data` | `map[string]any` | Host-specific response data | +## Bridge Helpers + +Two helpers simplify converting SDK client responses into orchestrator `Result` +values. + +### CollectionResult + +`CollectionResult` converts a collection response (any SDK call that returns +per-host results) into an orchestrator `Result` with populated `HostResults`. +Use it for most operations: + +```go +return orchestrator.CollectionResult( + resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, +), nil +``` + +The first argument is the SDK response data (which must have a `Results` field). +The second is a mapper function that converts each per-host result into an +`orchestrator.HostResult`. + +### StructToMap + +`StructToMap` converts a Go struct into a `map[string]any` using JSON +round-tripping. Use it for non-collection responses where you want to store the +full response in `Result.Data`: + +```go +return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), +}, nil +``` + ## TaskFuncWithResults Use `TaskFuncWithResults` when a task needs to read results from prior tasks: diff --git a/docs/plans/2026-03-13-orchestrator-op-layer.md b/docs/plans/2026-03-13-orchestrator-op-layer.md new file mode 100644 index 00000000..4d5719af --- /dev/null +++ b/docs/plans/2026-03-13-orchestrator-op-layer.md @@ -0,0 +1,361 @@ +# Orchestrator SDK Bridge Helpers Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. + +**Goal:** Add bridge helpers to the SDK orchestrator package, achieve 100% +coverage, fix all examples so they compile and are complete, update docs, then +remove the misplaced docker DSL. + +**Architecture:** The SDK orchestrator package provides the DAG engine (plan, +task, runner) plus bridge utilities (`CollectionResult`, `StructToMap`). +Domain-specific operation methods belong in consumer packages like +`osapi-orchestrator`, not in the SDK. Examples demonstrate the `TaskFunc` +pattern. + +**Tech Stack:** Go 1.25, generics, testify/suite, httptest + +--- + +### Task 1: Add CollectionResult and StructToMap bridge helpers + +**Files:** + +- Create: `pkg/sdk/orchestrator/bridge.go` +- Test: `pkg/sdk/orchestrator/bridge_public_test.go` + +**Step 1: Write tests for `StructToMap` and `CollectionResult`** + +Create `bridge_public_test.go` with `BridgePublicTestSuite`: + +Test `StructToMap`: + +- Converts struct with json tags to map +- Returns nil for nil input +- Handles nested structs +- Omits zero-value fields with `omitempty` + +Test `CollectionResult`: + +- Single result extracts JobID, Changed, HostResults with Data +- Multiple results — Changed is true when any host changed +- Empty results — returns result with empty HostResults +- HostResult.Data auto-populated via StructToMap when mapper leaves it nil +- HostResult.Data preserved when mapper sets it explicitly + +Use `client.HostnameResult`, `client.CommandResult` etc. as test inputs since +those are the real SDK types consumers will pass. + +**Step 2: Run tests to verify they fail** + +Run: `go test ./pkg/sdk/orchestrator/... -run TestBridgePublicTestSuite -v` +Expected: FAIL — `CollectionResult` and `StructToMap` not defined + +**Step 3: Implement `bridge.go`** + +```go +package orchestrator + +import ( + "encoding/json" + + osapiclient "github.com/retr0h/osapi/pkg/sdk/client" +) + +// StructToMap converts a struct to map[string]any using its JSON +// tags. Returns nil if v is nil or cannot be marshaled. +func StructToMap(v any) map[string]any + +// CollectionResult builds a Result from a Collection response. +// It iterates all results, applies the toHostResult mapper to +// build per-host details, and auto-populates HostResult.Data +// via StructToMap when the mapper leaves it nil. Changed is true +// if any host reported a change. +func CollectionResult[T any]( + col osapiclient.Collection[T], + toHostResult func(T) HostResult, +) *Result +``` + +Mirrors `osapi-orchestrator`'s `buildResult` and `toMap` — but exported and in +the SDK where it belongs. + +**Step 4: Run tests to verify they pass** + +Run: `go test ./pkg/sdk/orchestrator/... -run TestBridgePublicTestSuite -v` +Expected: PASS + +**Step 5: Check coverage** + +Run: +`go test ./pkg/sdk/orchestrator/... -coverprofile=/tmp/bridge.out && go tool cover -func=/tmp/bridge.out | grep bridge` +Expected: 100% on bridge.go + +**Step 6: Commit** + +``` +feat(orchestrator): add CollectionResult and StructToMap helpers +``` + +--- + +### Task 2: Delete docker.go DSL and its tests + +**Files:** + +- Delete: `pkg/sdk/orchestrator/docker.go` +- Delete: `pkg/sdk/orchestrator/docker_public_test.go` + +**Step 1: Delete the files** + +```bash +rm pkg/sdk/orchestrator/docker.go +rm pkg/sdk/orchestrator/docker_public_test.go +``` + +**Step 2: Run SDK tests** + +Run: `go test ./pkg/sdk/orchestrator/... -count=1` Expected: PASS (engine + +bridge tests pass, docker tests gone) + +**Step 3: Check what breaks** + +Run: `go build ./... 2>&1` Expected: Compilation failures in +`container-targeting.go` (references `plan.DockerPull` etc.). Note the failures +— fixed in Task 3. + +**Step 4: Commit** + +``` +refactor(orchestrator): remove docker DSL methods + +Domain-specific operation methods belong in consumer packages +like osapi-orchestrator, not in the SDK engine. The SDK provides +CollectionResult and StructToMap as bridge helpers instead. +``` + +--- + +### Task 3: Fix container-targeting example + +**Files:** + +- Modify: `examples/sdk/orchestrator/features/container-targeting.go` + +**Step 1: Rewrite to use `TaskFunc` with `CollectionResult`** + +Replace all `plan.DockerPull()`, `plan.DockerCreate()`, etc. with +`plan.TaskFunc()` calls that use the SDK client directly and +`orchestrator.CollectionResult()` to build results. + +Keep the same DAG structure: pre-cleanup → pull → create → exec x3 + inspect + +deliberately-fails → cleanup. + +Pre-cleanup remains a `TaskFunc` that swallows errors. + +Each docker operation becomes: + +```go +pull := plan.TaskFunc("pull-image", func( + ctx context.Context, + c *client.Client, +) (*orchestrator.Result, error) { + resp, err := c.Docker.Pull(ctx, target, gen.DockerPullRequest{ + Image: containerImage, + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerPullResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil +}) +``` + +**Step 2: Build** + +Run: `go build examples/sdk/orchestrator/features/container-targeting.go` +Expected: Compiles successfully + +**Step 3: Commit** + +``` +refactor: update container-targeting to use TaskFunc with bridge helpers +``` + +--- + +### Task 4: Fix all broken operation examples and add docker examples + +**Files:** + +- Modify: 13 files in `examples/sdk/orchestrator/operations/` that use + `plan.Task(&Op{...})` (all except `file-upload.go` which already uses + `TaskFunc`) +- Modify: feature examples that use `plan.Task(&Op{...})`: `basic.go`, + `broadcast.go`, `error-strategy.go`, `file-deploy-workflow.go`, `guards.go`, + `hooks.go`, `only-if-changed.go`, `parallel.go`, `result-decode.go`, + `task-func-results.go`, `task-func.go` +- Create: 8 docker operation examples: `docker-pull.go`, `docker-create.go`, + `docker-list.go`, `docker-inspect.go`, `docker-start.go`, `docker-stop.go`, + `docker-remove.go`, `docker-exec.go` + +**Step 1: Rewrite operation examples to use `TaskFunc`** + +Each currently does: + +```go +plan.Task("get-hostname", &orchestrator.Op{ + Operation: "node.hostname.get", + Target: "_any", +}) +``` + +Replace with: + +```go +plan.TaskFunc("get-hostname", func( + ctx context.Context, + c *client.Client, +) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil +}) +``` + +Apply this pattern to all 13 operation files and all feature files. + +For operations with params (command exec, DNS update, file deploy, etc.), unpack +from the example's local variables into the SDK request types directly — no +`Params map[string]any` needed. + +**Step 2: Create 8 docker operation examples** + +Follow the exact same pattern as node/command examples. One file per docker +operation in `examples/sdk/orchestrator/operations/`. + +**Step 3: Build every example individually** + +```bash +for f in examples/sdk/orchestrator/operations/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +for f in examples/sdk/orchestrator/features/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +``` + +Expected: ALL files compile. Zero failures. This is a hard gate — do not proceed +until every example compiles. + +**Step 4: Commit** + +``` +fix: rewrite all orchestrator examples to use TaskFunc +``` + +--- + +### Task 5: Update orchestrator docs + +**Files:** + +- Modify: `docs/docs/sidebar/sdk/orchestrator/orchestrator.md` +- Modify: all operation doc pages in + `docs/docs/sidebar/sdk/orchestrator/operations/` +- Modify: `docs/docs/sidebar/sdk/orchestrator/features/container-targeting.md` + +**Step 1: Update orchestrator overview** + +Add documentation for `CollectionResult` and `StructToMap` as SDK-provided +bridge helpers. Update the usage examples to show `TaskFunc` pattern instead of +`plan.Task(&Op{...})`. + +**Step 2: Update operation doc pages** + +Each page currently shows the `plan.Task(&Op{...})` pattern. Update to show +`plan.TaskFunc()` with `CollectionResult`. Match the code in the corresponding +example file exactly. + +**Step 3: Update container-targeting feature doc** + +Update code examples to match the rewritten `container-targeting.go`. + +**Step 4: Build docs** + +Run: `cd docs && bun run build` Expected: Build succeeds, no broken links + +**Step 5: Commit** + +``` +docs: update orchestrator docs for TaskFunc pattern +``` + +--- + +### Task 6: Final verification + +**Step 1: Full test suite** + +Run: `go test ./... -count=1` Expected: All packages pass + +**Step 2: SDK coverage check** + +Run: +`go test ./pkg/sdk/... -coverprofile=/tmp/sdk.out && go tool cover -func=/tmp/sdk.out | grep -v gen | grep -v '100.0%'` +Expected: All SDK packages at 100% (excluding gen) + +**Step 3: Lint and format** + +```bash +find . -type f -name '*.go' -not -name '*.gen.go' -not -name '*.pb.go' \ + -not -path './.worktrees/*' -not -path './.claude/*' \ + | xargs go tool github.com/segmentio/golines \ + --base-formatter="go tool mvdan.cc/gofumpt" -w +go tool github.com/golangci/golangci-lint/v2/cmd/golangci-lint run \ + --config .golangci.yml +``` + +Expected: 0 issues + +**Step 4: Verify all examples compile** + +```bash +for f in examples/sdk/orchestrator/operations/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +for f in examples/sdk/orchestrator/features/*.go; do + go build "$f" 2>&1 || echo "FAIL: $f" +done +``` + +Expected: Zero failures + +**Step 5: Build docs** + +Run: `cd docs && bun run build` Expected: No broken links + +**Step 6: Commit any remaining fixes** + +``` +chore: final verification cleanup +``` diff --git a/examples/sdk/orchestrator/features/basic.go b/examples/sdk/orchestrator/features/basic.go index f3b884be..6990bada 100644 --- a/examples/sdk/orchestrator/features/basic.go +++ b/examples/sdk/orchestrator/features/basic.go @@ -19,7 +19,7 @@ // DEALINGS IN THE SOFTWARE. // Package main demonstrates the simplest orchestrator plan: a health -// check followed by a hostname query using Op tasks with DependsOn. +// check followed by a hostname query using TaskFunc with DependsOn. // // DAG: // @@ -50,7 +50,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ BeforeTask: func(task *orchestrator.Task) { @@ -62,11 +62,14 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) health := plan.TaskFunc( "check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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) @@ -76,10 +79,28 @@ func main() { }, ) - hostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + hostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) hostname.DependsOn(health) report, err := plan.Run(context.Background()) diff --git a/examples/sdk/orchestrator/features/broadcast.go b/examples/sdk/orchestrator/features/broadcast.go index 3f7417d0..4a363104 100644 --- a/examples/sdk/orchestrator/features/broadcast.go +++ b/examples/sdk/orchestrator/features/broadcast.go @@ -51,7 +51,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -66,13 +66,31 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) // Target _all: delivered to every registered agent. - getAll := plan.Task("get-hostname-all", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_all", - }) + getAll := plan.TaskFunc( + "get-hostname-all", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_all") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) // Access per-host results from broadcast tasks. printHosts := plan.TaskFuncWithResults( diff --git a/examples/sdk/orchestrator/features/container-targeting.go b/examples/sdk/orchestrator/features/container-targeting.go index 745b1f7d..91d97bf5 100644 --- a/examples/sdk/orchestrator/features/container-targeting.go +++ b/examples/sdk/orchestrator/features/container-targeting.go @@ -113,44 +113,164 @@ func main() { // ── Pull image ─────────────────────────────────────────────── - pull := plan.DockerPull("pull-image", target, containerImage) + pull := plan.TaskFunc("pull-image", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Pull(ctx, target, gen.DockerPullRequest{ + Image: containerImage, + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerPullResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) pull.DependsOn(preCleanup) // ── Create container ───────────────────────────────────────── autoStart := true - create := plan.DockerCreate("create-container", target, - gen.DockerCreateRequest{ - Image: containerImage, - Name: ptr(containerName), - AutoStart: &autoStart, - Command: &[]string{"sleep", "600"}, + create := plan.TaskFunc("create-container", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Create(ctx, target, gen.DockerCreateRequest{ + Image: containerImage, + Name: ptr(containerName), + AutoStart: &autoStart, + Command: &[]string{"sleep", "600"}, + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, ) create.DependsOn(pull) // ── Exec: run commands inside the container ────────────────── - execHostname := plan.DockerExec("exec-hostname", target, containerName, - gen.DockerExecRequest{Command: []string{"hostname"}}, + execHostname := plan.TaskFunc("exec-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Exec(ctx, target, containerName, + gen.DockerExecRequest{Command: []string{"hostname"}}, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerExecResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, ) execHostname.DependsOn(create) - execUname := plan.DockerExec("exec-uname", target, containerName, - gen.DockerExecRequest{Command: []string{"uname", "-a"}}, + execUname := plan.TaskFunc("exec-uname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Exec(ctx, target, containerName, + gen.DockerExecRequest{Command: []string{"uname", "-a"}}, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerExecResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, ) execUname.DependsOn(create) - execOS := plan.DockerExec("exec-os-release", target, containerName, - gen.DockerExecRequest{ - Command: []string{"sh", "-c", "head -2 /etc/os-release"}, + execOS := plan.TaskFunc("exec-os-release", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Exec(ctx, target, containerName, + gen.DockerExecRequest{ + Command: []string{"sh", "-c", "head -2 /etc/os-release"}, + }, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerExecResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, ) execOS.DependsOn(create) // ── Inspect: read-only, reports unchanged ──────────────────── - inspect := plan.DockerInspect("inspect-container", target, containerName) + inspect := plan.TaskFunc("inspect-container", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Inspect(ctx, target, containerName) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerDetailResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) inspect.DependsOn(create) // ── Deliberately failing task: shows StatusFailed ───────────── @@ -168,8 +288,28 @@ func main() { // ── Cleanup ────────────────────────────────────────────────── // Depends on all tasks that use the container so it runs last. - plan.DockerRemove("cleanup", target, containerName, - &gen.DeleteNodeContainerDockerByIDParams{Force: &force}, + plan.TaskFunc("cleanup", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Docker.Remove(ctx, target, containerName, + &gen.DeleteNodeContainerDockerByIDParams{Force: &force}, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerActionResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, ).DependsOn(execHostname, execUname, execOS, inspect) // ── Run ────────────────────────────────────────────────────── diff --git a/examples/sdk/orchestrator/features/error-strategy.go b/examples/sdk/orchestrator/features/error-strategy.go index 508d94df..0988f5fa 100644 --- a/examples/sdk/orchestrator/features/error-strategy.go +++ b/examples/sdk/orchestrator/features/error-strategy.go @@ -52,7 +52,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -67,7 +67,7 @@ func main() { // Plan-level Continue: don't halt on failure. plan := orchestrator.NewPlan( - client, + apiClient, orchestrator.WithHooks(hooks), orchestrator.OnError(orchestrator.Continue), ) @@ -81,10 +81,28 @@ func main() { ) // Independent task — runs despite the failure above. - plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/features/file-deploy-workflow.go b/examples/sdk/orchestrator/features/file-deploy-workflow.go index 8310be9a..01753f46 100644 --- a/examples/sdk/orchestrator/features/file-deploy-workflow.go +++ b/examples/sdk/orchestrator/features/file-deploy-workflow.go @@ -53,7 +53,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ BeforeTask: func(task *orchestrator.Task) { @@ -65,12 +65,15 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, 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) { + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { tmpl := []byte(`# Generated for {{ .Hostname }} listen_address = {{ .Vars.listen_address }} workers = {{ .Facts.cpu_count }} @@ -94,29 +97,54 @@ workers = {{ .Facts.cpu_count }} ) // 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 := plan.TaskFunc( + "deploy-config", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileDeploy(ctx, client.FileDeployOpts{ + ObjectName: "app.conf.tmpl", + Path: "/tmp/app.conf", + ContentType: "template", + Mode: "0644", + Target: "_all", + Vars: map[string]any{ + "listen_address": "0.0.0.0:8080", + }, + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, - }) + ) 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 := plan.TaskFunc( + "verify-status", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.FileStatus(ctx, "_all", "/tmp/app.conf") + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, - }) + ) verify.DependsOn(deploy) report, err := plan.Run(context.Background()) diff --git a/examples/sdk/orchestrator/features/guards.go b/examples/sdk/orchestrator/features/guards.go index a40ee6a9..08f7f70e 100644 --- a/examples/sdk/orchestrator/features/guards.go +++ b/examples/sdk/orchestrator/features/guards.go @@ -52,7 +52,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -63,11 +63,14 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) health := plan.TaskFunc( "check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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) @@ -77,10 +80,28 @@ func main() { }, ) - getHostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + getHostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) getHostname.DependsOn(health) summary := plan.TaskFunc( diff --git a/examples/sdk/orchestrator/features/hooks.go b/examples/sdk/orchestrator/features/hooks.go index 4e46938e..42d9576f 100644 --- a/examples/sdk/orchestrator/features/hooks.go +++ b/examples/sdk/orchestrator/features/hooks.go @@ -53,7 +53,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ BeforePlan: func(summary orchestrator.PlanSummary) { @@ -90,12 +90,7 @@ func main() { 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()) - } + fmt.Printf(" [start] %s\n", task.Name()) }, AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { fmt.Printf(" [%s] %s changed=%v duration=%s\n", @@ -110,11 +105,14 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) health := plan.TaskFunc( "check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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) @@ -124,16 +122,52 @@ func main() { }, ) - hostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + hostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) hostname.DependsOn(health) - disk := plan.Task("get-disk", &orchestrator.Op{ - Operation: "node.disk.get", - Target: "_any", - }) + disk := plan.TaskFunc( + "get-disk", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Disk(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DiskResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) disk.DependsOn(health) _, err := plan.Run(context.Background()) diff --git a/examples/sdk/orchestrator/features/only-if-changed.go b/examples/sdk/orchestrator/features/only-if-changed.go index 08525ecb..fecd42a3 100644 --- a/examples/sdk/orchestrator/features/only-if-changed.go +++ b/examples/sdk/orchestrator/features/only-if-changed.go @@ -50,7 +50,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -62,12 +62,30 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) - - getHostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) + + getHostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) logChange := plan.TaskFunc( "log-change", diff --git a/examples/sdk/orchestrator/features/only-if-failed.go b/examples/sdk/orchestrator/features/only-if-failed.go index 2087d629..6c972cf6 100644 --- a/examples/sdk/orchestrator/features/only-if-failed.go +++ b/examples/sdk/orchestrator/features/only-if-failed.go @@ -51,7 +51,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -68,7 +68,7 @@ func main() { } plan := orchestrator.NewPlan( - client, + apiClient, orchestrator.WithHooks(hooks), orchestrator.OnError(orchestrator.Continue), ) diff --git a/examples/sdk/orchestrator/features/parallel.go b/examples/sdk/orchestrator/features/parallel.go index 3f3ca6a2..bbca43aa 100644 --- a/examples/sdk/orchestrator/features/parallel.go +++ b/examples/sdk/orchestrator/features/parallel.go @@ -53,7 +53,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ BeforeLevel: func(level int, tasks []*orchestrator.Task, parallel bool) { @@ -75,11 +75,14 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) health := plan.TaskFunc( "check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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) @@ -91,17 +94,77 @@ func main() { // 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) - } + hostnameTask := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + hostnameTask.DependsOn(health) + + diskTask := plan.TaskFunc( + "get-disk", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Disk(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DiskResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + diskTask.DependsOn(health) + + memoryTask := plan.TaskFunc( + "get-memory", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Memory(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.MemoryResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + memoryTask.DependsOn(health) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/features/result-decode.go b/examples/sdk/orchestrator/features/result-decode.go index c4961f77..5fd37d14 100644 --- a/examples/sdk/orchestrator/features/result-decode.go +++ b/examples/sdk/orchestrator/features/result-decode.go @@ -51,13 +51,31 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) - plan := orchestrator.NewPlan(client) + apiClient := client.New(url, token) + plan := orchestrator.NewPlan(apiClient) - plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/features/task-func-results.go b/examples/sdk/orchestrator/features/task-func-results.go index cd6f354e..1cbc9098 100644 --- a/examples/sdk/orchestrator/features/task-func-results.go +++ b/examples/sdk/orchestrator/features/task-func-results.go @@ -20,7 +20,7 @@ // Package main demonstrates TaskFuncWithResults for reading data from // previously completed tasks. The summary step reads hostname data -// set by a prior Op task. +// set by a prior TaskFunc. // // DAG: // @@ -52,7 +52,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -60,11 +60,14 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) health := plan.TaskFunc( "check-health", - func(ctx context.Context, c *client.Client) (*orchestrator.Result, error) { + 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) @@ -74,10 +77,28 @@ func main() { }, ) - getHostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + getHostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) getHostname.DependsOn(health) // TaskFuncWithResults: access completed task data via Results.Get(). diff --git a/examples/sdk/orchestrator/features/task-func.go b/examples/sdk/orchestrator/features/task-func.go index 1bebf5d3..ed6c3ab5 100644 --- a/examples/sdk/orchestrator/features/task-func.go +++ b/examples/sdk/orchestrator/features/task-func.go @@ -24,7 +24,7 @@ // DAG: // // check-health (TaskFunc) -// └── get-hostname (Op) +// └── get-hostname (TaskFunc) // // Run with: OSAPI_TOKEN="" go run task-func.go package main @@ -50,7 +50,7 @@ func main() { log.Fatal("OSAPI_TOKEN is required") } - client := client.New(url, token) + apiClient := client.New(url, token) hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { @@ -59,7 +59,7 @@ func main() { }, } - plan := orchestrator.NewPlan(client, orchestrator.WithHooks(hooks)) + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks)) // TaskFunc: run arbitrary Go code as a plan step. health := plan.TaskFunc( @@ -77,10 +77,28 @@ func main() { }, ) - hostname := plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + hostname := plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + c *client.Client, + ) (*orchestrator.Result, error) { + resp, err := c.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) hostname.DependsOn(health) report, err := plan.Run(context.Background()) diff --git a/examples/sdk/orchestrator/operations/command-exec.go b/examples/sdk/orchestrator/operations/command-exec.go index a68e1816..c8c99fb6 100644 --- a/examples/sdk/orchestrator/operations/command-exec.go +++ b/examples/sdk/orchestrator/operations/command-exec.go @@ -57,13 +57,31 @@ func main() { 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", + plan.TaskFunc( + "exec-uptime", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Exec(ctx, client.ExecRequest{ + Command: "uptime", + Target: "_any", + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.CommandResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/command-shell.go b/examples/sdk/orchestrator/operations/command-shell.go index 81aa7bf7..3bfb2855 100644 --- a/examples/sdk/orchestrator/operations/command-shell.go +++ b/examples/sdk/orchestrator/operations/command-shell.go @@ -57,13 +57,31 @@ func main() { 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)", + plan.TaskFunc( + "shell-echo", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Shell(ctx, client.ShellRequest{ + Command: "echo hello from $(hostname)", + Target: "_any", + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.CommandResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/docker-create.go b/examples/sdk/orchestrator/operations/docker-create.go new file mode 100644 index 00000000..8782f82d --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-create.go @@ -0,0 +1,99 @@ +// 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 docker.create operation, which creates +// a new container on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-create.go +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" + "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( + "create-container", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Create(ctx, "_any", gen.DockerCreateRequest{ + Image: "nginx:latest", + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-exec.go b/examples/sdk/orchestrator/operations/docker-exec.go new file mode 100644 index 00000000..b47b8420 --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-exec.go @@ -0,0 +1,99 @@ +// 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 docker.exec operation, which executes +// a command inside a running container on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-exec.go +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" + "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( + "exec-hostname", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Exec(ctx, "_any", "container-name", + gen.DockerExecRequest{Command: []string{"hostname"}}, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerExecResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-inspect.go b/examples/sdk/orchestrator/operations/docker-inspect.go new file mode 100644 index 00000000..d8099c4a --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-inspect.go @@ -0,0 +1,96 @@ +// 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 docker.inspect operation, which +// retrieves detailed information about a specific container. +// +// Run with: OSAPI_TOKEN="" go run docker-inspect.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.TaskFunc( + "inspect-container", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Inspect(ctx, "_any", "container-name") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerDetailResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-list.go b/examples/sdk/orchestrator/operations/docker-list.go new file mode 100644 index 00000000..e93b4c94 --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-list.go @@ -0,0 +1,96 @@ +// 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 docker.list operation, which lists +// containers on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-list.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.TaskFunc( + "list-containers", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.List(ctx, "_any", nil) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerListResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-pull.go b/examples/sdk/orchestrator/operations/docker-pull.go new file mode 100644 index 00000000..9186d6d1 --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-pull.go @@ -0,0 +1,99 @@ +// 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 docker.pull operation, which pulls +// a container image on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-pull.go +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" + "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( + "pull-image", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Pull(ctx, "_any", gen.DockerPullRequest{ + Image: "alpine:latest", + }) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerPullResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-remove.go b/examples/sdk/orchestrator/operations/docker-remove.go new file mode 100644 index 00000000..8adf85d1 --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-remove.go @@ -0,0 +1,96 @@ +// 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 docker.remove operation, which removes +// a container from the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-remove.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.TaskFunc( + "remove-container", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Remove(ctx, "_any", "container-name", nil) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerActionResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-start.go b/examples/sdk/orchestrator/operations/docker-start.go new file mode 100644 index 00000000..ba9b083d --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-start.go @@ -0,0 +1,96 @@ +// 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 docker.start operation, which starts +// a stopped container on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-start.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.TaskFunc( + "start-container", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Start(ctx, "_any", "container-name") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerActionResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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/docker-stop.go b/examples/sdk/orchestrator/operations/docker-stop.go new file mode 100644 index 00000000..64a35b60 --- /dev/null +++ b/examples/sdk/orchestrator/operations/docker-stop.go @@ -0,0 +1,99 @@ +// 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 docker.stop operation, which stops +// a running container on the target node. +// +// Run with: OSAPI_TOKEN="" go run docker-stop.go +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + + "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/client/gen" + "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( + "stop-container", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Docker.Stop( + ctx, "_any", "container-name", gen.DockerStopRequest{}, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DockerActionResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) + + 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 index 6253e8da..b51d7e4b 100644 --- a/examples/sdk/orchestrator/operations/file-deploy.go +++ b/examples/sdk/orchestrator/operations/file-deploy.go @@ -57,16 +57,30 @@ func main() { 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", + plan.TaskFunc( + "deploy-config", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.FileDeploy(ctx, client.FileDeployOpts{ + ObjectName: "app.conf", + Path: "/etc/app/config.yaml", + ContentType: "static", + Mode: "0644", + Target: "_all", + }) + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/file-status.go b/examples/sdk/orchestrator/operations/file-status.go index a20ccf82..c7367969 100644 --- a/examples/sdk/orchestrator/operations/file-status.go +++ b/examples/sdk/orchestrator/operations/file-status.go @@ -57,13 +57,24 @@ func main() { 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", + plan.TaskFunc( + "check-file", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.FileStatus(ctx, "_any", "/etc/app/config.yaml") + if err != nil { + return nil, err + } + + return &orchestrator.Result{ + JobID: resp.Data.JobID, + Changed: resp.Data.Changed, + Data: orchestrator.StructToMap(resp.Data), + }, nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/network-dns-get.go b/examples/sdk/orchestrator/operations/network-dns-get.go index 9b654de6..cb1fd560 100644 --- a/examples/sdk/orchestrator/operations/network-dns-get.go +++ b/examples/sdk/orchestrator/operations/network-dns-get.go @@ -57,13 +57,28 @@ func main() { 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", + plan.TaskFunc( + "get-dns", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.GetDNS(ctx, "_any", "eth0") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DNSConfig) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/network-dns-update.go b/examples/sdk/orchestrator/operations/network-dns-update.go index 4bde9e3c..32a4cf26 100644 --- a/examples/sdk/orchestrator/operations/network-dns-update.go +++ b/examples/sdk/orchestrator/operations/network-dns-update.go @@ -57,14 +57,31 @@ func main() { 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"}, + plan.TaskFunc( + "update-dns", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.UpdateDNS( + ctx, "_any", "eth0", + []string{"8.8.8.8", "8.8.4.4"}, nil, + ) + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DNSUpdateResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/network-ping.go b/examples/sdk/orchestrator/operations/network-ping.go index 59ea42ab..0ae09d34 100644 --- a/examples/sdk/orchestrator/operations/network-ping.go +++ b/examples/sdk/orchestrator/operations/network-ping.go @@ -57,14 +57,28 @@ func main() { 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, + plan.TaskFunc( + "ping-host", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Ping(ctx, "_any", "8.8.8.8") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.PingResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil }, - }) + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-disk.go b/examples/sdk/orchestrator/operations/node-disk.go index 41fd3b24..3cf975c5 100644 --- a/examples/sdk/orchestrator/operations/node-disk.go +++ b/examples/sdk/orchestrator/operations/node-disk.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-disk", &orchestrator.Op{ - Operation: "node.disk.get", - Target: "_any", - }) + plan.TaskFunc( + "get-disk", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Disk(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.DiskResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-hostname.go b/examples/sdk/orchestrator/operations/node-hostname.go index f76493fb..c6d701a0 100644 --- a/examples/sdk/orchestrator/operations/node-hostname.go +++ b/examples/sdk/orchestrator/operations/node-hostname.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-hostname", &orchestrator.Op{ - Operation: "node.hostname.get", - Target: "_any", - }) + plan.TaskFunc( + "get-hostname", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Hostname(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-load.go b/examples/sdk/orchestrator/operations/node-load.go index 776645ba..f822b677 100644 --- a/examples/sdk/orchestrator/operations/node-load.go +++ b/examples/sdk/orchestrator/operations/node-load.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-load", &orchestrator.Op{ - Operation: "node.load.get", - Target: "_any", - }) + plan.TaskFunc( + "get-load", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Load(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.LoadResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-memory.go b/examples/sdk/orchestrator/operations/node-memory.go index b70e34f6..25088df5 100644 --- a/examples/sdk/orchestrator/operations/node-memory.go +++ b/examples/sdk/orchestrator/operations/node-memory.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-memory", &orchestrator.Op{ - Operation: "node.memory.get", - Target: "_any", - }) + plan.TaskFunc( + "get-memory", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Memory(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.MemoryResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-status.go b/examples/sdk/orchestrator/operations/node-status.go index 96dcf83d..3daf5819 100644 --- a/examples/sdk/orchestrator/operations/node-status.go +++ b/examples/sdk/orchestrator/operations/node-status.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-status", &orchestrator.Op{ - Operation: "node.status.get", - Target: "_any", - }) + plan.TaskFunc( + "get-status", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Status(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.NodeStatus) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/examples/sdk/orchestrator/operations/node-uptime.go b/examples/sdk/orchestrator/operations/node-uptime.go index 5b6c2947..fc18da8f 100644 --- a/examples/sdk/orchestrator/operations/node-uptime.go +++ b/examples/sdk/orchestrator/operations/node-uptime.go @@ -57,10 +57,28 @@ func main() { plan := orchestrator.NewPlan(c, orchestrator.WithHooks(hooks)) - plan.Task("get-uptime", &orchestrator.Op{ - Operation: "node.uptime.get", - Target: "_any", - }) + plan.TaskFunc( + "get-uptime", + func( + ctx context.Context, + cc *client.Client, + ) (*orchestrator.Result, error) { + resp, err := cc.Node.Uptime(ctx, "_any") + if err != nil { + return nil, err + } + + return orchestrator.CollectionResult(resp.Data, + func(r client.UptimeResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + }, + ), nil + }, + ) report, err := plan.Run(context.Background()) if err != nil { diff --git a/pkg/sdk/orchestrator/bridge.go b/pkg/sdk/orchestrator/bridge.go new file mode 100644 index 00000000..55881bd7 --- /dev/null +++ b/pkg/sdk/orchestrator/bridge.go @@ -0,0 +1,65 @@ +package orchestrator + +import ( + "encoding/json" + + client "github.com/retr0h/osapi/pkg/sdk/client" +) + +// jsonUnmarshalFn is the JSON unmarshal function (injectable for testing). +var jsonUnmarshalFn = json.Unmarshal + +// StructToMap converts a struct to map[string]any using its JSON tags. +// Returns nil if v is nil or cannot be marshaled. +func StructToMap( + v any, +) map[string]any { + if v == nil { + return nil + } + + b, err := json.Marshal(v) + if err != nil { + return nil + } + + var m map[string]any + if err := jsonUnmarshalFn(b, &m); err != nil { + return nil + } + + return m +} + +// CollectionResult builds a Result from a Collection response. +// It iterates all results, applies the toHostResult mapper to build +// per-host details, and auto-populates HostResult.Data via StructToMap +// when the mapper leaves it nil. Changed is true if any host reported +// a change. +func CollectionResult[T any]( + col client.Collection[T], + toHostResult func(T) HostResult, +) *Result { + hostResults := make([]HostResult, 0, len(col.Results)) + changed := false + + for _, r := range col.Results { + hr := toHostResult(r) + + if hr.Data == nil { + hr.Data = StructToMap(r) + } + + if hr.Changed { + changed = true + } + + hostResults = append(hostResults, hr) + } + + return &Result{ + JobID: col.JobID, + Changed: changed, + HostResults: hostResults, + } +} diff --git a/pkg/sdk/orchestrator/bridge_public_test.go b/pkg/sdk/orchestrator/bridge_public_test.go new file mode 100644 index 00000000..8133b333 --- /dev/null +++ b/pkg/sdk/orchestrator/bridge_public_test.go @@ -0,0 +1,220 @@ +package orchestrator_test + +import ( + "testing" + + "github.com/stretchr/testify/suite" + + client "github.com/retr0h/osapi/pkg/sdk/client" + "github.com/retr0h/osapi/pkg/sdk/orchestrator" +) + +type BridgePublicTestSuite struct { + suite.Suite +} + +func TestBridgePublicTestSuite(t *testing.T) { + suite.Run(t, new(BridgePublicTestSuite)) +} + +type testStruct struct { + Name string `json:"name"` + Value int `json:"value"` +} + +type testNested struct { + Label string `json:"label"` + Inner testStruct `json:"inner"` +} + +func (s *BridgePublicTestSuite) TestStructToMap() { + tests := []struct { + name string + input any + validateFn func(m map[string]any) + }{ + { + name: "converts struct with json tags to map", + input: testStruct{Name: "web-01", Value: 42}, + validateFn: func(m map[string]any) { + s.Require().NotNil(m) + s.Equal("web-01", m["name"]) + s.Equal(float64(42), m["value"]) + }, + }, + { + name: "returns nil for nil input", + input: nil, + validateFn: func(m map[string]any) { + s.Nil(m) + }, + }, + { + name: "handles nested structs", + input: testNested{ + Label: "parent", + Inner: testStruct{Name: "child", Value: 7}, + }, + validateFn: func(m map[string]any) { + s.Require().NotNil(m) + s.Equal("parent", m["label"]) + + inner, ok := m["inner"].(map[string]any) + s.Require().True(ok) + s.Equal("child", inner["name"]) + s.Equal(float64(7), inner["value"]) + }, + }, + { + name: "converts struct without json tags using field names", + input: client.HostnameResult{ + Hostname: "web-01", + Changed: true, + }, + validateFn: func(m map[string]any) { + s.Require().NotNil(m) + s.Equal("web-01", m["Hostname"]) + s.Equal(true, m["Changed"]) + }, + }, + { + name: "returns nil for unmarshalable input", + input: make(chan int), + validateFn: func(m map[string]any) { + s.Nil(m) + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + got := orchestrator.StructToMap(tt.input) + tt.validateFn(got) + }) + } +} + +func (s *BridgePublicTestSuite) TestCollectionResult() { + mapper := func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + } + } + + tests := []struct { + name string + col client.Collection[client.HostnameResult] + toHost func(client.HostnameResult) orchestrator.HostResult + validateFn func(result *orchestrator.Result) + }{ + { + name: "single result with auto-populated data", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01", Changed: false}, + }, + JobID: "job-123", + }, + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Equal("job-123", result.JobID) + s.False(result.Changed) + s.Require().Len(result.HostResults, 1) + + hr := result.HostResults[0] + s.Equal("web-01", hr.Hostname) + s.False(hr.Changed) + s.Require().NotNil(hr.Data, "Data should be auto-populated via StructToMap") + s.Equal("web-01", hr.Data["Hostname"]) + }, + }, + { + name: "multiple results with changed true when any host changed", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "web-01", Changed: false}, + {Hostname: "web-02", Changed: true}, + }, + JobID: "job-456", + }, + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Equal("job-456", result.JobID) + s.True(result.Changed) + s.Len(result.HostResults, 2) + s.False(result.HostResults[0].Changed) + s.True(result.HostResults[1].Changed) + }, + }, + { + name: "empty results returns result with empty host results", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{}, + JobID: "job-789", + }, + toHost: mapper, + validateFn: func(result *orchestrator.Result) { + s.Equal("job-789", result.JobID) + s.False(result.Changed) + s.Empty(result.HostResults) + }, + }, + { + name: "data auto-populated via StructToMap when mapper leaves it nil", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "db-01", Changed: false, Error: "timeout"}, + }, + JobID: "job-auto", + }, + toHost: func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Error: r.Error, + // Data intentionally left nil + } + }, + validateFn: func(result *orchestrator.Result) { + hr := result.HostResults[0] + s.Require().NotNil(hr.Data) + s.Equal("db-01", hr.Data["Hostname"]) + s.Equal("timeout", hr.Data["Error"]) + }, + }, + { + name: "data preserved when mapper sets it explicitly", + col: client.Collection[client.HostnameResult]{ + Results: []client.HostnameResult{ + {Hostname: "app-01", Changed: true}, + }, + JobID: "job-explicit", + }, + toHost: func(r client.HostnameResult) orchestrator.HostResult { + return orchestrator.HostResult{ + Hostname: r.Hostname, + Changed: r.Changed, + Data: map[string]any{"custom": "value"}, + } + }, + validateFn: func(result *orchestrator.Result) { + hr := result.HostResults[0] + s.Require().NotNil(hr.Data) + s.Equal("value", hr.Data["custom"]) + // Should NOT contain auto-populated fields + _, hasHostname := hr.Data["Hostname"] + s.False(hasHostname, "mapper-set Data should not be overwritten") + }, + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + result := orchestrator.CollectionResult(tt.col, tt.toHost) + s.Require().NotNil(result) + tt.validateFn(result) + }) + } +} diff --git a/pkg/sdk/orchestrator/bridge_test.go b/pkg/sdk/orchestrator/bridge_test.go new file mode 100644 index 00000000..a69a5935 --- /dev/null +++ b/pkg/sdk/orchestrator/bridge_test.go @@ -0,0 +1,44 @@ +package orchestrator + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/suite" +) + +type BridgeTestSuite struct { + suite.Suite +} + +func (s *BridgeTestSuite) TestStructToMapUnmarshalError() { + original := jsonUnmarshalFn + defer func() { jsonUnmarshalFn = original }() + + jsonUnmarshalFn = func( + _ []byte, + _ any, + ) error { + return fmt.Errorf("forced unmarshal error") + } + + result := StructToMap(struct { + Name string `json:"name"` + }{Name: "test"}) + + s.Nil(result) + + // Restore and verify normal behavior. + jsonUnmarshalFn = json.Unmarshal + + result = StructToMap(struct { + Name string `json:"name"` + }{Name: "test"}) + s.NotNil(result) + s.Equal("test", result["name"]) +} + +func TestBridgeTestSuite(t *testing.T) { + suite.Run(t, new(BridgeTestSuite)) +} diff --git a/pkg/sdk/orchestrator/docker.go b/pkg/sdk/orchestrator/docker.go deleted file mode 100644 index 7c1af2a3..00000000 --- a/pkg/sdk/orchestrator/docker.go +++ /dev/null @@ -1,249 +0,0 @@ -package orchestrator - -import ( - "context" - - osapiclient "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" -) - -// DockerPull creates a task that pulls a Docker image on the target host. -func (p *Plan) DockerPull( - name string, - target string, - image string, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Pull(ctx, target, gen.DockerPullRequest{ - Image: image, - }) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "image_id": r.ImageID, - "tag": r.Tag, - "size": r.Size, - }, - }, nil - }) -} - -// DockerCreate creates a task that creates a Docker container on the -// target host. -func (p *Plan) DockerCreate( - name string, - target string, - body gen.DockerCreateRequest, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Create(ctx, target, body) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "id": r.ID, - "name": r.Name, - "image": r.Image, - "state": r.State, - }, - }, nil - }) -} - -// DockerStart creates a task that starts a Docker container on the -// target host. -func (p *Plan) DockerStart( - name string, - target string, - id string, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Start(ctx, target, id) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "id": r.ID, - "message": r.Message, - }, - }, nil - }) -} - -// DockerStop creates a task that stops a Docker container on the -// target host. -func (p *Plan) DockerStop( - name string, - target string, - id string, - body gen.DockerStopRequest, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Stop(ctx, target, id, body) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "id": r.ID, - "message": r.Message, - }, - }, nil - }) -} - -// DockerRemove creates a task that removes a Docker container from the -// target host. -func (p *Plan) DockerRemove( - name string, - target string, - id string, - params *gen.DeleteNodeContainerDockerByIDParams, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Remove(ctx, target, id, params) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "id": r.ID, - "message": r.Message, - }, - }, nil - }) -} - -// DockerExec creates a task that executes a command in a Docker -// container. -func (p *Plan) DockerExec( - name string, - target string, - id string, - body gen.DockerExecRequest, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Exec(ctx, target, id, body) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: r.Changed, - Data: map[string]any{ - "stdout": r.Stdout, - "stderr": r.Stderr, - "exit_code": r.ExitCode, - }, - }, nil - }) -} - -// DockerInspect creates a task that inspects a Docker container on the -// target host. -func (p *Plan) DockerInspect( - name string, - target string, - id string, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.Inspect(ctx, target, id) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: false, - Data: map[string]any{ - "id": r.ID, - "name": r.Name, - "image": r.Image, - "state": r.State, - }, - }, nil - }) -} - -// DockerList creates a task that lists Docker containers on the target -// host. -func (p *Plan) DockerList( - name string, - target string, - params *gen.GetNodeContainerDockerParams, -) *Task { - return p.TaskFunc(name, func( - ctx context.Context, - c *osapiclient.Client, - ) (*Result, error) { - resp, err := c.Docker.List(ctx, target, params) - if err != nil { - return nil, err - } - - r := resp.Data.Results[0] - - return &Result{ - JobID: resp.Data.JobID, - Changed: false, - Data: map[string]any{ - "containers": r.Containers, - }, - }, nil - }) -} diff --git a/pkg/sdk/orchestrator/docker_public_test.go b/pkg/sdk/orchestrator/docker_public_test.go deleted file mode 100644 index 0bbda3e2..00000000 --- a/pkg/sdk/orchestrator/docker_public_test.go +++ /dev/null @@ -1,711 +0,0 @@ -package orchestrator_test - -import ( - "context" - "net/http" - "net/http/httptest" - "testing" - - "github.com/stretchr/testify/suite" - - osapiclient "github.com/retr0h/osapi/pkg/sdk/client" - "github.com/retr0h/osapi/pkg/sdk/client/gen" - "github.com/retr0h/osapi/pkg/sdk/orchestrator" -) - -type DockerPublicTestSuite struct { - suite.Suite -} - -func (s *DockerPublicTestSuite) TestDockerPull() { - tests := []struct { - name string - taskName string - target string - image string - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "pull-image", - target: "_any", - image: "ubuntu:24.04", - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("pull-image", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "pull-image", - target: "_any", - image: "alpine:latest", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000001","results":[{"hostname":"h1","image_id":"sha256:abc","tag":"latest","size":1024,"changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000001", result.JobID) - s.True(result.Changed) - s.Equal("sha256:abc", result.Data["image_id"]) - s.Equal("latest", result.Data["tag"]) - s.Equal(int64(1024), result.Data["size"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "pull-image", - target: "_any", - image: "alpine:latest", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerPull(tt.taskName, tt.target, tt.image) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerCreate() { - tests := []struct { - name string - taskName string - target string - body gen.DockerCreateRequest - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "create-container", - target: "_any", - body: gen.DockerCreateRequest{Image: "nginx:latest"}, - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("create-container", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "create-container", - target: "_any", - body: gen.DockerCreateRequest{Image: "nginx:latest"}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000002","results":[{"hostname":"h1","id":"c1","name":"web","image":"nginx:latest","state":"created","changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000002", result.JobID) - s.True(result.Changed) - s.Equal("c1", result.Data["id"]) - s.Equal("web", result.Data["name"]) - s.Equal("nginx:latest", result.Data["image"]) - s.Equal("created", result.Data["state"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "create-container", - target: "_any", - body: gen.DockerCreateRequest{Image: "nginx:latest"}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerCreate(tt.taskName, tt.target, tt.body) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerStart() { - tests := []struct { - name string - taskName string - target string - id string - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "start-container", - target: "_any", - id: "abc123", - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("start-container", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "start-container", - target: "_any", - id: "abc123", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000003","results":[{"hostname":"h1","id":"abc123","message":"started","changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000003", result.JobID) - s.True(result.Changed) - s.Equal("abc123", result.Data["id"]) - s.Equal("started", result.Data["message"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "start-container", - target: "_any", - id: "abc123", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerStart(tt.taskName, tt.target, tt.id) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerStop() { - tests := []struct { - name string - taskName string - target string - id string - body gen.DockerStopRequest - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "stop-container", - target: "_any", - id: "abc123", - body: gen.DockerStopRequest{}, - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("stop-container", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "stop-container", - target: "_any", - id: "abc123", - body: gen.DockerStopRequest{}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000004","results":[{"hostname":"h1","id":"abc123","message":"stopped","changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000004", result.JobID) - s.True(result.Changed) - s.Equal("abc123", result.Data["id"]) - s.Equal("stopped", result.Data["message"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "stop-container", - target: "_any", - id: "abc123", - body: gen.DockerStopRequest{}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerStop(tt.taskName, tt.target, tt.id, tt.body) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerRemove() { - tests := []struct { - name string - taskName string - target string - id string - params *gen.DeleteNodeContainerDockerByIDParams - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "remove-container", - target: "_any", - id: "abc123", - params: nil, - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("remove-container", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "remove-container", - target: "_any", - id: "abc123", - params: nil, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000005","results":[{"hostname":"h1","id":"abc123","message":"removed","changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000005", result.JobID) - s.True(result.Changed) - s.Equal("abc123", result.Data["id"]) - s.Equal("removed", result.Data["message"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "remove-container", - target: "_any", - id: "abc123", - params: nil, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerRemove(tt.taskName, tt.target, tt.id, tt.params) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerExec() { - tests := []struct { - name string - taskName string - target string - id string - body gen.DockerExecRequest - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "exec-cmd", - target: "_any", - id: "abc123", - body: gen.DockerExecRequest{Command: []string{"hostname"}}, - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("exec-cmd", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "exec-cmd", - target: "_any", - id: "abc123", - body: gen.DockerExecRequest{Command: []string{"hostname"}}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusAccepted) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000006","results":[{"hostname":"h1","stdout":"web-01\n","stderr":"","exit_code":0,"changed":true}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000006", result.JobID) - s.True(result.Changed) - s.Equal("web-01\n", result.Data["stdout"]) - s.Equal("", result.Data["stderr"]) - s.Equal(0, result.Data["exit_code"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "exec-cmd", - target: "_any", - id: "abc123", - body: gen.DockerExecRequest{Command: []string{"hostname"}}, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerExec(tt.taskName, tt.target, tt.id, tt.body) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerInspect() { - tests := []struct { - name string - taskName string - target string - id string - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "inspect-container", - target: "_any", - id: "abc123", - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("inspect-container", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "inspect-container", - target: "_any", - id: "abc123", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000007","results":[{"hostname":"h1","id":"abc123","name":"web","image":"nginx:latest","state":"running"}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000007", result.JobID) - s.False(result.Changed) - s.Equal("abc123", result.Data["id"]) - s.Equal("web", result.Data["name"]) - s.Equal("nginx:latest", result.Data["image"]) - s.Equal("running", result.Data["state"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "inspect-container", - target: "_any", - id: "abc123", - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerInspect(tt.taskName, tt.target, tt.id) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func (s *DockerPublicTestSuite) TestDockerList() { - tests := []struct { - name string - taskName string - target string - params *gen.GetNodeContainerDockerParams - handler http.HandlerFunc - validateFunc func(*orchestrator.Task, *osapiclient.Client) - }{ - { - name: "creates task with correct name", - taskName: "list-containers", - target: "_any", - params: nil, - validateFunc: func( - task *orchestrator.Task, - _ *osapiclient.Client, - ) { - s.NotNil(task) - s.Equal("list-containers", task.Name()) - }, - }, - { - name: "executes closure and returns result", - taskName: "list-containers", - target: "_any", - params: nil, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte( - `{"job_id":"00000000-0000-0000-0000-000000000008","results":[{"hostname":"h1","containers":[{"id":"c1","name":"web","image":"nginx","state":"running"}]}]}`, - )) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.NoError(err) - s.Equal("00000000-0000-0000-0000-000000000008", result.JobID) - s.False(result.Changed) - s.NotNil(result.Data["containers"]) - }, - }, - { - name: "returns error when SDK call fails", - taskName: "list-containers", - target: "_any", - params: nil, - handler: func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"error":"forbidden"}`)) - }, - validateFunc: func( - task *orchestrator.Task, - c *osapiclient.Client, - ) { - result, err := task.Fn()(context.Background(), c) - s.Error(err) - s.Nil(result) - }, - }, - } - - for _, tt := range tests { - s.Run(tt.name, func() { - var c *osapiclient.Client - if tt.handler != nil { - srv := httptest.NewServer(tt.handler) - defer srv.Close() - c = osapiclient.New(srv.URL, "token") - } - - plan := orchestrator.NewPlan(c) - task := plan.DockerList(tt.taskName, tt.target, tt.params) - tt.validateFunc(task, c) - s.Len(plan.Tasks(), 1) - }) - } -} - -func TestDockerPublicTestSuite(t *testing.T) { - suite.Run(t, new(DockerPublicTestSuite)) -}