From 214455381f1a29a52d8190c17ab7e36ea1c18a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 12 Mar 2026 09:43:53 -0700 Subject: [PATCH 1/4] feat(sdk): add ContainerProvider and auto-deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add typed ContainerProvider that wraps RuntimeTarget to execute provider operations inside Docker containers with full type safety. Automatically deploys the osapi binary into containers from GitHub releases on first use via Prepare/sync.Once. Also extracts platform detection into pkg/sdk/platform and refactors the provider registry into its own file. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude JIRA: None --- cmd/provider_run.go | 13 +- cmd/provider_run_registry.go | 318 +++++++ .../features/container-targeting.go | 459 +++++++++- internal/agent/factory.go | 29 +- internal/agent/factory_test.go | 7 +- pkg/sdk/orchestrator/container_provider.go | 356 ++++++++ .../container_provider_public_test.go | 820 ++++++++++++++++++ .../orchestrator/container_provider_test.go | 79 ++ pkg/sdk/orchestrator/deploy.go | 137 +++ pkg/sdk/orchestrator/deploy_public_test.go | 203 +++++ pkg/sdk/orchestrator/deploy_test.go | 325 +++++++ pkg/sdk/orchestrator/docker_target.go | 87 +- .../orchestrator/docker_target_public_test.go | 2 + pkg/sdk/orchestrator/options.go | 22 + pkg/sdk/orchestrator/options_public_test.go | 40 + pkg/sdk/orchestrator/plan.go | 18 +- pkg/sdk/orchestrator/plan_in.go | 11 +- pkg/sdk/platform/platform.go | 50 ++ pkg/sdk/platform/platform_public_test.go | 96 ++ 19 files changed, 2987 insertions(+), 85 deletions(-) create mode 100644 cmd/provider_run_registry.go create mode 100644 pkg/sdk/orchestrator/container_provider.go create mode 100644 pkg/sdk/orchestrator/container_provider_public_test.go create mode 100644 pkg/sdk/orchestrator/container_provider_test.go create mode 100644 pkg/sdk/orchestrator/deploy.go create mode 100644 pkg/sdk/orchestrator/deploy_public_test.go create mode 100644 pkg/sdk/orchestrator/deploy_test.go create mode 100644 pkg/sdk/platform/platform.go create mode 100644 pkg/sdk/platform/platform_public_test.go diff --git a/cmd/provider_run.go b/cmd/provider_run.go index 05582c65..5ed89a42 100644 --- a/cmd/provider_run.go +++ b/cmd/provider_run.go @@ -27,8 +27,6 @@ import ( "os" "github.com/spf13/cobra" - - "github.com/retr0h/osapi/internal/provider/registry" ) var providerRunData string @@ -51,7 +49,10 @@ var providerRunCmd = &cobra.Command{ return fmt.Errorf("unknown provider/operation: %s/%s", providerName, operationName) } - params := spec.NewParams() + var params any + if spec.NewParams != nil { + params = spec.NewParams() + } if providerRunData != "" && params != nil { if err := json.Unmarshal([]byte(providerRunData), params); err != nil { return fmt.Errorf("parse input data: %w", err) @@ -86,9 +87,3 @@ func init() { rootCmd.AddCommand(providerCmd) } -// buildProviderRegistry creates a registry with all known provider operations. -// This is a minimal initial implementation -- more providers will be registered -// as the provider run feature matures. -func buildProviderRegistry() *registry.Registry { - return registry.New() -} diff --git a/cmd/provider_run_registry.go b/cmd/provider_run_registry.go new file mode 100644 index 00000000..8d5a57b1 --- /dev/null +++ b/cmd/provider_run_registry.go @@ -0,0 +1,318 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package cmd + +import ( + "context" + "io" + "log/slog" + + "github.com/retr0h/osapi/internal/exec" + "github.com/retr0h/osapi/internal/provider/command" + "github.com/retr0h/osapi/internal/provider/network/dns" + "github.com/retr0h/osapi/internal/provider/network/ping" + "github.com/retr0h/osapi/internal/provider/node/disk" + nodeHost "github.com/retr0h/osapi/internal/provider/node/host" + "github.com/retr0h/osapi/internal/provider/node/load" + "github.com/retr0h/osapi/internal/provider/node/mem" + "github.com/retr0h/osapi/internal/provider/registry" + "github.com/retr0h/osapi/pkg/sdk/platform" +) + +// PingParams holds the input for the ping.do operation. +type PingParams struct { + Address string `json:"address"` +} + +// DNSGetParams holds the input for dns.get-resolv-conf. +type DNSGetParams struct { + InterfaceName string `json:"interface_name"` +} + +// DNSUpdateParams holds the input for dns.update-resolv-conf. +type DNSUpdateParams struct { + Servers []string `json:"servers"` + SearchDomains []string `json:"search_domains"` + InterfaceName string `json:"interface_name"` +} + +// buildProviderRegistry creates a registry with all known provider operations, +// using platform detection to select the correct provider variant (Ubuntu, +// Darwin, or generic Linux). +func buildProviderRegistry() *registry.Registry { + reg := registry.New() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + execManager := exec.New(logger) + plat := platform.Detect() + + registerHostProvider(reg, plat) + registerDiskProvider(reg, plat, logger) + registerMemProvider(reg, plat) + registerLoadProvider(reg, plat) + registerPingProvider(reg, plat) + registerDNSProvider(reg, plat, logger, execManager) + registerCommandProvider(reg, logger, execManager) + + return reg +} + +func registerHostProvider( + reg *registry.Registry, + platform string, +) { + var p nodeHost.Provider + switch platform { + case "ubuntu": + p = nodeHost.NewUbuntuProvider() + case "darwin": + p = nodeHost.NewDarwinProvider() + default: + p = nodeHost.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "host", + Operations: map[string]registry.OperationSpec{ + "get-hostname": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetHostname() + }, + }, + "get-uptime": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetUptime() + }, + }, + "get-os-info": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetOSInfo() + }, + }, + "get-architecture": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetArchitecture() + }, + }, + "get-kernel-version": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetKernelVersion() + }, + }, + "get-fqdn": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetFQDN() + }, + }, + "get-cpu-count": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetCPUCount() + }, + }, + "get-service-manager": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetServiceManager() + }, + }, + "get-package-manager": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetPackageManager() + }, + }, + }, + }) +} + +func registerDiskProvider( + reg *registry.Registry, + platform string, + logger *slog.Logger, +) { + var p disk.Provider + switch platform { + case "ubuntu": + p = disk.NewUbuntuProvider(logger) + case "darwin": + p = disk.NewDarwinProvider(logger) + default: + p = disk.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "disk", + Operations: map[string]registry.OperationSpec{ + "get-local-usage-stats": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetLocalUsageStats() + }, + }, + }, + }) +} + +func registerMemProvider( + reg *registry.Registry, + platform string, +) { + var p mem.Provider + switch platform { + case "ubuntu": + p = mem.NewUbuntuProvider() + case "darwin": + p = mem.NewDarwinProvider() + default: + p = mem.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "mem", + Operations: map[string]registry.OperationSpec{ + "get-stats": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetStats() + }, + }, + }, + }) +} + +func registerLoadProvider( + reg *registry.Registry, + platform string, +) { + var p load.Provider + switch platform { + case "ubuntu": + p = load.NewUbuntuProvider() + case "darwin": + p = load.NewDarwinProvider() + default: + p = load.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "load", + Operations: map[string]registry.OperationSpec{ + "get-average-stats": { + Run: func(_ context.Context, _ any) (any, error) { + return p.GetAverageStats() + }, + }, + }, + }) +} + +func registerPingProvider( + reg *registry.Registry, + platform string, +) { + var p ping.Provider + switch platform { + case "ubuntu": + p = ping.NewUbuntuProvider() + case "darwin": + p = ping.NewDarwinProvider() + default: + p = ping.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "ping", + Operations: map[string]registry.OperationSpec{ + "do": { + NewParams: func() any { return &PingParams{} }, + Run: func(_ context.Context, params any) (any, error) { + pp := params.(*PingParams) + return p.Do(pp.Address) + }, + }, + }, + }) +} + +func registerDNSProvider( + reg *registry.Registry, + platform string, + logger *slog.Logger, + em exec.Manager, +) { + var p dns.Provider + switch platform { + case "ubuntu": + p = dns.NewUbuntuProvider(logger, em) + case "darwin": + p = dns.NewDarwinProvider(logger, em) + default: + p = dns.NewLinuxProvider() + } + + reg.Register(registry.Registration{ + Name: "dns", + Operations: map[string]registry.OperationSpec{ + "get-resolv-conf": { + NewParams: func() any { return &DNSGetParams{} }, + Run: func(_ context.Context, params any) (any, error) { + pp := params.(*DNSGetParams) + return p.GetResolvConfByInterface(pp.InterfaceName) + }, + }, + "update-resolv-conf": { + NewParams: func() any { return &DNSUpdateParams{} }, + Run: func(_ context.Context, params any) (any, error) { + pp := params.(*DNSUpdateParams) + return p.UpdateResolvConfByInterface( + pp.Servers, + pp.SearchDomains, + pp.InterfaceName, + ) + }, + }, + }, + }) +} + +func registerCommandProvider( + reg *registry.Registry, + logger *slog.Logger, + em exec.Manager, +) { + p := command.New(logger, em) + + reg.Register(registry.Registration{ + Name: "command", + Operations: map[string]registry.OperationSpec{ + "exec": { + NewParams: func() any { return &command.ExecParams{} }, + Run: func(_ context.Context, params any) (any, error) { + pp := params.(*command.ExecParams) + return p.Exec(*pp) + }, + }, + "shell": { + NewParams: func() any { return &command.ShellParams{} }, + Run: func(_ context.Context, params any) (any, error) { + pp := params.(*command.ShellParams) + return p.Shell(*pp) + }, + }, + }, + }) +} diff --git a/examples/sdk/orchestrator/features/container-targeting.go b/examples/sdk/orchestrator/features/container-targeting.go index 5f671e24..38434030 100644 --- a/examples/sdk/orchestrator/features/container-targeting.go +++ b/examples/sdk/orchestrator/features/container-targeting.go @@ -18,15 +18,24 @@ // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER // DEALINGS IN THE SOFTWARE. -// Package main demonstrates container targeting for running provider -// operations inside Docker containers using the orchestrator DSL. +// Package main demonstrates running provider operations inside Docker +// containers using the orchestrator SDK's typed ContainerProvider. // -// DAG: +// The example exercises every read-only provider, command execution, +// error handling for failing commands, and a deliberately failing task +// to show how the orchestrator reports each status. // -// pull-image -// └── create-container -// └── exec-inside (scoped via In) -// └── cleanup +// Expected output statuses: +// +// changed — setup tasks (ensure-clean, pull, create, deploy) and command exec +// unchanged — all read-only providers (host, mem, load, disk) +// failed — deliberately-fails task (returns an error) +// skipped — none (OnError(Continue) lets independent tasks proceed) +// +// Prerequisites: +// - A running OSAPI stack (API server + agent + NATS) +// - Docker available on the agent host +// - Go toolchain (the example builds osapi for linux automatically) // // Run with: OSAPI_TOKEN="" go run container-targeting.go package main @@ -36,12 +45,19 @@ import ( "fmt" "log" "os" + osexec "os/exec" + "runtime" + "strings" + "time" "github.com/retr0h/osapi/pkg/sdk/client" "github.com/retr0h/osapi/pkg/sdk/client/gen" "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) +const containerName = "example-provider-container" +const containerImage = "ubuntu:24.04" + func ptr(s string) *string { return &s } func main() { @@ -62,21 +78,54 @@ func main() { apiClient := client.New(url, token) + // ── Plan setup ──────────────────────────────────────────────── + // + // OnError(Continue) keeps independent tasks running after a + // failure so the report shows all statuses: changed, unchanged, + // failed. The AfterTask hook prints each result as it completes. + hooks := orchestrator.Hooks{ AfterTask: func(_ *orchestrator.Task, result orchestrator.TaskResult) { - fmt.Printf(" [%s] %s changed=%v\n", - result.Status, result.Name, result.Changed) + status := fmt.Sprintf("[%s]", result.Status) + if result.Error != nil { + status += " " + result.Error.Error() + } + fmt.Printf(" %-12s %-25s changed=%v\n", + status, result.Name, result.Changed) }, } - // WithDockerExecFn is required for Plan.Docker() to work. - // In a real application, this would use the Docker SDK's - // ContainerExecCreate/ContainerExecAttach APIs. + // WithDockerExecFn bridges the orchestrator's Docker DSL to the + // OSAPI Container.Exec REST API. Every ExecProvider call flows + // through: SDK → REST API → NATS → agent → docker exec. + dockerExecFn := func( + ctx context.Context, + containerID string, + command []string, + ) (stdout, stderr string, exitCode int, err error) { + resp, execErr := apiClient.Container.Exec( + ctx, + target, + containerID, + gen.ContainerExecRequest{Command: command}, + ) + if execErr != nil { + return "", "", -1, execErr + } + + r := resp.Data.Results[0] + + return r.Stdout, r.Stderr, r.ExitCode, nil + } + plan := orchestrator.NewPlan(apiClient, orchestrator.WithHooks(hooks), + orchestrator.WithDockerExecFn(dockerExecFn), + orchestrator.OnError(orchestrator.Continue), ) - // Pull the image first. + // ── Setup: pull + create + deploy osapi ────────────────────── + pull := plan.TaskFunc( "pull-image", func( @@ -84,7 +133,7 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Container.Pull(ctx, target, gen.ContainerPullRequest{ - Image: "ubuntu:24.04", + Image: containerImage, }) if err != nil { return nil, fmt.Errorf("pull: %w", err) @@ -99,9 +148,7 @@ func main() { }, ) - // Create the container. autoStart := true - create := plan.TaskFunc( "create-container", func( @@ -109,13 +156,14 @@ func main() { c *client.Client, ) (*orchestrator.Result, error) { resp, err := c.Container.Create(ctx, target, gen.ContainerCreateRequest{ - Image: "ubuntu:24.04", - Name: ptr("example-container"), + Image: containerImage, + Name: ptr(containerName), AutoStart: &autoStart, - Command: &[]string{"sleep", "300"}, + Command: &[]string{"sleep", "600"}, }) if err != nil { - return nil, fmt.Errorf("create: %w", err) + // Container already exists — carry on. + return &orchestrator.Result{}, nil } r := resp.Data.Results[0] @@ -128,37 +176,334 @@ func main() { ) create.DependsOn(pull) - // Exec a command inside the container. - execInside := plan.TaskFunc( - "exec-inside", + // Build osapi for linux and docker cp it into the container. + // In production you would bake the binary into the image. + deploy := plan.TaskFunc( + "deploy-osapi", func( - ctx context.Context, - c *client.Client, + _ context.Context, + _ *client.Client, ) (*orchestrator.Result, error) { - resp, err := c.Container.Exec( - ctx, - target, - "example-container", - gen.ContainerExecRequest{ - Command: []string{"cat", "/etc/os-release"}, - }, + tmpBin := "/tmp/osapi-container" + + build := osexec.Command( + "go", "build", "-o", tmpBin, "github.com/retr0h/osapi", ) + build.Dir = findProjectRoot() + build.Env = append(os.Environ(), "GOOS=linux", "GOARCH="+runtime.GOARCH) + if out, err := build.CombinedOutput(); err != nil { + return nil, fmt.Errorf("build osapi: %s: %w", string(out), err) + } + + cp := osexec.Command("docker", "cp", tmpBin, containerName+":/osapi") + if out, err := cp.CombinedOutput(); err != nil { + return nil, fmt.Errorf("docker cp: %s: %w", string(out), err) + } + + _ = os.Remove(tmpBin) + + return &orchestrator.Result{Changed: true}, nil + }, + ) + deploy.DependsOn(create) + + // ── Provider: typed ContainerProvider bound to the target ────── + + dockerTarget := plan.Docker(containerName, containerImage) + provider := orchestrator.NewContainerProvider(dockerTarget) + scoped := plan.In(dockerTarget) + + // ── Host provider: 9 read-only operations (unchanged) ───────── + + getHostname := scoped.TaskFunc( + "host/get-hostname", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetHostname(ctx) if err != nil { - return nil, fmt.Errorf("exec: %w", err) + return nil, err } + fmt.Printf(" hostname = %s\n", v) - r := resp.Data.Results[0] - fmt.Printf("\n --- stdout ---\n%s\n", r.Stdout) + return &orchestrator.Result{Data: map[string]any{"hostname": v}}, nil + }, + ) + getHostname.DependsOn(deploy) + + getOSInfo := scoped.TaskFunc( + "host/get-os-info", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + info, err := provider.GetOSInfo(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" os = %s %s\n", info.Distribution, info.Version) return &orchestrator.Result{ - Changed: false, + Changed: info.Changed, + Data: map[string]any{ + "distribution": info.Distribution, + "version": info.Version, + }, + }, nil + }, + ) + getOSInfo.DependsOn(deploy) + + getArch := scoped.TaskFunc( + "host/get-architecture", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetArchitecture(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" architecture = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"architecture": v}}, nil + }, + ) + getArch.DependsOn(deploy) + + getKernel := scoped.TaskFunc( + "host/get-kernel-version", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetKernelVersion(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" kernel = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"kernel": v}}, nil + }, + ) + getKernel.DependsOn(deploy) + + getUptime := scoped.TaskFunc( + "host/get-uptime", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetUptime(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" uptime = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"uptime": v.String()}}, nil + }, + ) + getUptime.DependsOn(deploy) + + getFQDN := scoped.TaskFunc( + "host/get-fqdn", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetFQDN(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" fqdn = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"fqdn": v}}, nil + }, + ) + getFQDN.DependsOn(deploy) + + getCPUCount := scoped.TaskFunc( + "host/get-cpu-count", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetCPUCount(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" cpu_count = %d\n", v) + + return &orchestrator.Result{Data: map[string]any{"cpu_count": v}}, nil + }, + ) + getCPUCount.DependsOn(deploy) + + getSvcMgr := scoped.TaskFunc( + "host/get-service-manager", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetServiceManager(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" service_mgr = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"service_manager": v}}, nil + }, + ) + getSvcMgr.DependsOn(deploy) + + getPkgMgr := scoped.TaskFunc( + "host/get-package-manager", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + v, err := provider.GetPackageManager(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" package_mgr = %s\n", v) + + return &orchestrator.Result{Data: map[string]any{"package_manager": v}}, nil + }, + ) + getPkgMgr.DependsOn(deploy) + + // ── Memory, load, disk providers (unchanged) ────────────────── + + getMemStats := scoped.TaskFunc( + "mem/get-stats", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + stats, err := provider.GetMemStats(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" mem_total = %d MB\n", stats.Total/1024/1024) + fmt.Printf(" mem_available = %d MB\n", stats.Available/1024/1024) + + return &orchestrator.Result{ + Changed: stats.Changed, + Data: map[string]any{ + "total": stats.Total, + "available": stats.Available, + }, + }, nil + }, + ) + getMemStats.DependsOn(deploy) + + getLoadStats := scoped.TaskFunc( + "load/get-average-stats", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + stats, err := provider.GetLoadStats(ctx) + if err != nil { + return nil, err + } + fmt.Printf(" load1 = %.2f\n", stats.Load1) + fmt.Printf(" load5 = %.2f\n", stats.Load5) + fmt.Printf(" load15 = %.2f\n", stats.Load15) + + return &orchestrator.Result{ + Changed: stats.Changed, + Data: map[string]any{ + "load1": stats.Load1, "load5": stats.Load5, "load15": stats.Load15, + }, + }, nil + }, + ) + getLoadStats.DependsOn(deploy) + + getDiskUsage := scoped.TaskFunc( + "disk/get-usage", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + disks, err := provider.GetDiskUsage(ctx) + if err != nil { + return nil, err + } + for _, d := range disks { + fmt.Printf(" disk %-8s total=%d MB used=%d MB\n", + d.Name, d.Total/1024/1024, d.Used/1024/1024) + } + + return &orchestrator.Result{Data: map[string]any{"mounts": len(disks)}}, nil + }, + ) + getDiskUsage.DependsOn(deploy) + + // ── Command provider: exec + shell (changed) ────────────────── + + execUname := scoped.TaskFunc( + "command/exec-uname", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + r, err := provider.Exec(ctx, orchestrator.ExecParams{ + Command: "uname", + Args: []string{"-a"}, + }) + if err != nil { + return nil, err + } + fmt.Printf(" uname -a = %s", r.Stdout) + + return &orchestrator.Result{ + Changed: r.Changed, + Data: map[string]any{"exit_code": r.ExitCode}, + }, nil + }, + ) + execUname.DependsOn(deploy) + + // Reads /etc/os-release inside the container to prove we are + // running inside Ubuntu 24.04, not the host OS. + shellOSRelease := scoped.TaskFunc( + "command/shell-os-release", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + r, err := provider.Shell(ctx, orchestrator.ShellParams{ + Command: "head -2 /etc/os-release && echo container-hostname=$(hostname)", + }) + if err != nil { + return nil, err + } + fmt.Printf(" os-release =\n%s", r.Stdout) + + return &orchestrator.Result{ + Changed: r.Changed, Data: map[string]any{"exit_code": r.ExitCode}, }, nil }, ) - execInside.DependsOn(create) + shellOSRelease.DependsOn(deploy) + + // ── Command that exits non-zero: handled gracefully ─────────── + // + // The command provider returns the exit code in the result. + // The task inspects it and reports unchanged (no system mutation) + // rather than failing the task. + + execFails := scoped.TaskFunc( + "command/exec-nonzero", + func(ctx context.Context, _ *client.Client) (*orchestrator.Result, error) { + r, err := provider.Exec(ctx, orchestrator.ExecParams{ + Command: "ls", + Args: []string{"/does-not-exist"}, + }) + if err != nil { + return nil, err + } + + fmt.Printf(" exit_code = %d\n", r.ExitCode) + fmt.Printf(" stderr = %s", r.Stderr) + + return &orchestrator.Result{ + Changed: r.Changed, + Data: map[string]any{ + "exit_code": r.ExitCode, + "stderr": r.Stderr, + }, + }, nil + }, + ) + execFails.DependsOn(deploy) + + // ── Deliberately failing task: shows StatusFailed ────────────── + // + // Returning an error from the task function marks it as failed. + // With OnError(Continue), independent tasks keep running but + // any task that DependsOn this one would be skipped. + + deliberatelyFails := scoped.TaskFunc( + "deliberately-fails", + func( + _ context.Context, + _ *client.Client, + ) (*orchestrator.Result, error) { + return nil, fmt.Errorf("this task always fails to demonstrate error reporting") + }, + ) + deliberatelyFails.DependsOn(deploy) + + // ── Cleanup ─────────────────────────────────────────────────── + // + // Depends on all provider tasks EXCEPT deliberately-fails so + // that cleanup is not skipped when OnError(Continue) is active. - // Clean up: remove the container. cleanup := plan.TaskFunc( "cleanup", func( @@ -169,7 +514,7 @@ func main() { _, err := c.Container.Remove( ctx, target, - "example-container", + containerName, &gen.DeleteNodeContainerByIDParams{Force: &force}, ) if err != nil { @@ -179,12 +524,44 @@ func main() { return &orchestrator.Result{Changed: true}, nil }, ) - cleanup.DependsOn(execInside) + cleanup.DependsOn( + getHostname, getOSInfo, getArch, getKernel, getUptime, + getFQDN, getCPUCount, getSvcMgr, getPkgMgr, + getMemStats, getLoadStats, getDiskUsage, + execUname, shellOSRelease, execFails, + ) + + // Suppress unused variable warning — deliberately-fails has no + // dependents by design. + _ = deliberatelyFails + + // ── Run ─────────────────────────────────────────────────────── + + fmt.Println("=== Container Provider Example ===") + fmt.Println() report, err := plan.Run(context.Background()) if err != nil { log.Fatal(err) } - fmt.Printf("\n%s in %s\n", report.Summary(), report.Duration) + fmt.Printf("\n=== Summary: %s in %s ===\n", report.Summary(), report.Duration.Truncate(time.Millisecond)) +} + +// findProjectRoot walks up from the current directory to find go.mod. +func findProjectRoot() string { + dir, _ := os.Getwd() + + for { + if _, err := os.Stat(dir + "/go.mod"); err == nil { + return dir + } + + idx := strings.LastIndex(dir, "/") + if idx <= 0 { + return "." + } + + dir = dir[:idx] + } } diff --git a/internal/agent/factory.go b/internal/agent/factory.go index 49939873..cf22c687 100644 --- a/internal/agent/factory.go +++ b/internal/agent/factory.go @@ -23,9 +23,6 @@ package agent import ( "context" "log/slog" - "strings" - - "github.com/shirou/gopsutil/v4/host" "github.com/retr0h/osapi/internal/exec" "github.com/retr0h/osapi/internal/provider/command" @@ -38,11 +35,9 @@ import ( nodeHost "github.com/retr0h/osapi/internal/provider/node/host" "github.com/retr0h/osapi/internal/provider/node/load" "github.com/retr0h/osapi/internal/provider/node/mem" + "github.com/retr0h/osapi/pkg/sdk/platform" ) -// factoryHostInfoFn is the function used to get host info (injectable for testing). -var factoryHostInfoFn = host.Info - // factoryDockerNewFn is the function used to create a Docker driver (injectable for testing). var factoryDockerNewFn = docker.New @@ -72,19 +67,15 @@ func (f *ProviderFactory) CreateProviders() ( command.Provider, containerProv.Provider, ) { - info, _ := factoryHostInfoFn() - platform := strings.ToLower(info.Platform) - if platform == "" && strings.ToLower(info.OS) == "darwin" { - platform = "darwin" - } + plat := platform.Detect() - if platform == "darwin" { + if plat == "darwin" { f.logger.Info("running on darwin") } // Create system providers var hostProvider nodeHost.Provider - switch platform { + switch plat { case "ubuntu": hostProvider = nodeHost.NewUbuntuProvider() case "darwin": @@ -94,7 +85,7 @@ func (f *ProviderFactory) CreateProviders() ( } var diskProvider disk.Provider - switch platform { + switch plat { case "ubuntu": diskProvider = disk.NewUbuntuProvider(f.logger) case "darwin": @@ -104,7 +95,7 @@ func (f *ProviderFactory) CreateProviders() ( } var memProvider mem.Provider - switch platform { + switch plat { case "ubuntu": memProvider = mem.NewUbuntuProvider() case "darwin": @@ -114,7 +105,7 @@ func (f *ProviderFactory) CreateProviders() ( } var loadProvider load.Provider - switch platform { + switch plat { case "ubuntu": loadProvider = load.NewUbuntuProvider() case "darwin": @@ -126,7 +117,7 @@ func (f *ProviderFactory) CreateProviders() ( // Create network providers var dnsProvider dns.Provider execManager := exec.New(f.logger) - switch platform { + switch plat { case "ubuntu": dnsProvider = dns.NewUbuntuProvider(f.logger, execManager) case "darwin": @@ -136,7 +127,7 @@ func (f *ProviderFactory) CreateProviders() ( } var pingProvider ping.Provider - switch platform { + switch plat { case "ubuntu": pingProvider = ping.NewUbuntuProvider() case "darwin": @@ -147,7 +138,7 @@ func (f *ProviderFactory) CreateProviders() ( // Create network info provider var netinfoProvider netinfo.Provider - switch platform { + switch plat { case "darwin": netinfoProvider = netinfo.NewDarwinProvider(execManager) default: diff --git a/internal/agent/factory_test.go b/internal/agent/factory_test.go index a9cd2c8d..6655d154 100644 --- a/internal/agent/factory_test.go +++ b/internal/agent/factory_test.go @@ -33,6 +33,7 @@ import ( "github.com/stretchr/testify/suite" "github.com/retr0h/osapi/internal/provider/container/runtime/docker" + "github.com/retr0h/osapi/pkg/sdk/platform" ) // testDockerClient is a minimal mock that satisfies dockerclient.APIClient @@ -141,14 +142,14 @@ func (s *FactoryTestSuite) TestCreateProviders() { for _, tt := range tests { s.Run(tt.name, func() { - originalHost := factoryHostInfoFn + originalHost := platform.HostInfoFn originalDocker := factoryDockerNewFn defer func() { - factoryHostInfoFn = originalHost + platform.HostInfoFn = originalHost factoryDockerNewFn = originalDocker }() - factoryHostInfoFn = tt.setupMock() + platform.HostInfoFn = tt.setupMock() if tt.setupDocker != nil { tt.setupDocker() } diff --git a/pkg/sdk/orchestrator/container_provider.go b/pkg/sdk/orchestrator/container_provider.go new file mode 100644 index 00000000..62a6580c --- /dev/null +++ b/pkg/sdk/orchestrator/container_provider.go @@ -0,0 +1,356 @@ +// 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 orchestrator + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// ContainerProvider provides typed access to provider operations inside +// a container via the RuntimeTarget's ExecProvider method. This runs +// `osapi provider run ` inside the container, +// so the osapi binary must be present at /osapi in the container. +// +// The SDK owns the input/output type contracts. The CLI's `provider run` +// command and this typed layer use the same serialization format. +type ContainerProvider struct { + target RuntimeTarget +} + +// NewContainerProvider creates a provider bound to a RuntimeTarget. +func NewContainerProvider( + target RuntimeTarget, +) *ContainerProvider { + return &ContainerProvider{target: target} +} + +// ── Result types ───────────────────────────────────────────────────── +// +// These mirror the internal provider result types and define the JSON +// contract between the `osapi provider run` CLI and SDK consumers. + +// HostOSInfo contains operating system information. +type HostOSInfo struct { + Distribution string `json:"Distribution"` + Version string `json:"Version"` + Changed bool `json:"changed"` +} + +// MemStats contains memory statistics in bytes. +type MemStats struct { + Total uint64 `json:"Total"` + Available uint64 `json:"Available"` + Free uint64 `json:"Free"` + Cached uint64 `json:"Cached"` + Changed bool `json:"changed"` +} + +// LoadStats contains load average statistics. +type LoadStats struct { + Load1 float32 `json:"Load1"` + Load5 float32 `json:"Load5"` + Load15 float32 `json:"Load15"` + Changed bool `json:"changed"` +} + +// DiskUsage contains disk usage statistics for a single mount point. +type DiskUsage struct { + Name string `json:"Name"` + Total uint64 `json:"Total"` + Used uint64 `json:"Used"` + Free uint64 `json:"Free"` + Changed bool `json:"changed"` +} + +// CommandResult contains the output of a command execution. +type CommandResult struct { + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + ExitCode int `json:"exit_code"` + DurationMs int64 `json:"duration_ms"` + Changed bool `json:"changed"` +} + +// ── Input types ────────────────────────────────────────────────────── +// +// These mirror the internal provider param types. + +// ExecParams contains parameters for direct command execution. +type ExecParams struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Cwd string `json:"cwd,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +// ShellParams contains parameters for shell command execution. +type ShellParams struct { + Command string `json:"command"` + Cwd string `json:"cwd,omitempty"` + Timeout int `json:"timeout,omitempty"` +} + +// PingParams contains parameters for the ping operation. +type PingParams struct { + Address string `json:"address"` +} + +// PingResult contains the result of a ping operation. +type PingResult struct { + PacketsSent int `json:"PacketsSent"` + PacketsReceived int `json:"PacketsReceived"` + PacketLoss float64 `json:"PacketLoss"` + MinRTT time.Duration `json:"MinRTT"` + AvgRTT time.Duration `json:"AvgRTT"` + MaxRTT time.Duration `json:"MaxRTT"` + Changed bool `json:"changed"` +} + +// DNSGetParams contains parameters for DNS configuration retrieval. +type DNSGetParams struct { + InterfaceName string `json:"interface_name"` +} + +// DNSGetResult contains DNS configuration. +type DNSGetResult struct { + DNSServers []string `json:"DNSServers"` + SearchDomains []string `json:"SearchDomains"` +} + +// DNSUpdateParams contains parameters for DNS configuration update. +type DNSUpdateParams struct { + Servers []string `json:"servers"` + SearchDomains []string `json:"search_domains"` + InterfaceName string `json:"interface_name"` +} + +// DNSUpdateResult contains the result of a DNS update operation. +type DNSUpdateResult struct { + Changed bool `json:"changed"` +} + +// ── Helper ─────────────────────────────────────────────────────────── + +// run executes a provider operation and unmarshals the JSON result. +func run[T any]( + ctx context.Context, + cp *ContainerProvider, + provider string, + operation string, + params any, +) (*T, error) { + var data []byte + if params != nil { + var err error + data, err = json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshal %s/%s params: %w", provider, operation, err) + } + } + + out, err := cp.target.ExecProvider(ctx, provider, operation, data) + if err != nil { + return nil, err + } + + var result T + if err := json.Unmarshal(out, &result); err != nil { + return nil, fmt.Errorf("unmarshal %s/%s result: %w", provider, operation, err) + } + + return &result, nil +} + +// runScalar executes a provider operation that returns a JSON scalar +// (string, int, float). +func runScalar[T any]( + ctx context.Context, + cp *ContainerProvider, + provider string, + operation string, +) (T, error) { + var zero T + + out, err := cp.target.ExecProvider(ctx, provider, operation, nil) + if err != nil { + return zero, err + } + + var result T + if err := json.Unmarshal(out, &result); err != nil { + return zero, fmt.Errorf("unmarshal %s/%s result: %w", provider, operation, err) + } + + return result, nil +} + +// ── Host operations ────────────────────────────────────────────────── + +// GetHostname returns the container's hostname. +func (cp *ContainerProvider) GetHostname( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-hostname") +} + +// GetOSInfo returns the container's OS distribution and version. +func (cp *ContainerProvider) GetOSInfo( + ctx context.Context, +) (*HostOSInfo, error) { + return run[HostOSInfo](ctx, cp, "host", "get-os-info", nil) +} + +// GetArchitecture returns the CPU architecture (e.g., x86_64, aarch64). +func (cp *ContainerProvider) GetArchitecture( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-architecture") +} + +// GetKernelVersion returns the kernel version string. +func (cp *ContainerProvider) GetKernelVersion( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-kernel-version") +} + +// GetUptime returns the system uptime as a duration. +func (cp *ContainerProvider) GetUptime( + ctx context.Context, +) (time.Duration, error) { + return runScalar[time.Duration](ctx, cp, "host", "get-uptime") +} + +// GetFQDN returns the fully qualified domain name. +func (cp *ContainerProvider) GetFQDN( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-fqdn") +} + +// GetCPUCount returns the number of logical CPUs. +func (cp *ContainerProvider) GetCPUCount( + ctx context.Context, +) (int, error) { + return runScalar[int](ctx, cp, "host", "get-cpu-count") +} + +// GetServiceManager returns the system service manager (e.g., systemd). +func (cp *ContainerProvider) GetServiceManager( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-service-manager") +} + +// GetPackageManager returns the system package manager (e.g., apt, dnf). +func (cp *ContainerProvider) GetPackageManager( + ctx context.Context, +) (string, error) { + return runScalar[string](ctx, cp, "host", "get-package-manager") +} + +// ── Memory operations ──────────────────────────────────────────────── + +// GetMemStats returns memory statistics. +func (cp *ContainerProvider) GetMemStats( + ctx context.Context, +) (*MemStats, error) { + return run[MemStats](ctx, cp, "mem", "get-stats", nil) +} + +// ── Load operations ────────────────────────────────────────────────── + +// GetLoadStats returns load average statistics. +func (cp *ContainerProvider) GetLoadStats( + ctx context.Context, +) (*LoadStats, error) { + return run[LoadStats](ctx, cp, "load", "get-average-stats", nil) +} + +// ── Disk operations ────────────────────────────────────────────────── + +// GetDiskUsage returns disk usage statistics for all local mounts. +func (cp *ContainerProvider) GetDiskUsage( + ctx context.Context, +) ([]DiskUsage, error) { + out, err := cp.target.ExecProvider(ctx, "disk", "get-local-usage-stats", nil) + if err != nil { + return nil, err + } + + var result []DiskUsage + if err := json.Unmarshal(out, &result); err != nil { + return nil, fmt.Errorf("unmarshal disk/get-local-usage-stats result: %w", err) + } + + return result, nil +} + +// ── Command operations ─────────────────────────────────────────────── + +// Exec runs a command inside the container via the command provider. +func (cp *ContainerProvider) Exec( + ctx context.Context, + params ExecParams, +) (*CommandResult, error) { + return run[CommandResult](ctx, cp, "command", "exec", ¶ms) +} + +// Shell runs a shell command inside the container via the command provider. +func (cp *ContainerProvider) Shell( + ctx context.Context, + params ShellParams, +) (*CommandResult, error) { + return run[CommandResult](ctx, cp, "command", "shell", ¶ms) +} + +// ── Ping operations ────────────────────────────────────────────────── + +// Ping pings an address from inside the container. +func (cp *ContainerProvider) Ping( + ctx context.Context, + address string, +) (*PingResult, error) { + return run[PingResult](ctx, cp, "ping", "do", &PingParams{Address: address}) +} + +// ── DNS operations ─────────────────────────────────────────────────── + +// GetDNS returns the DNS configuration for the given interface. +func (cp *ContainerProvider) GetDNS( + ctx context.Context, + interfaceName string, +) (*DNSGetResult, error) { + return run[DNSGetResult](ctx, cp, "dns", "get-resolv-conf", &DNSGetParams{ + InterfaceName: interfaceName, + }) +} + +// UpdateDNS updates the DNS configuration for the given interface. +func (cp *ContainerProvider) UpdateDNS( + ctx context.Context, + params DNSUpdateParams, +) (*DNSUpdateResult, error) { + return run[DNSUpdateResult](ctx, cp, "dns", "update-resolv-conf", ¶ms) +} diff --git a/pkg/sdk/orchestrator/container_provider_public_test.go b/pkg/sdk/orchestrator/container_provider_public_test.go new file mode 100644 index 00000000..2530d476 --- /dev/null +++ b/pkg/sdk/orchestrator/container_provider_public_test.go @@ -0,0 +1,820 @@ +// 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 orchestrator_test + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/orchestrator" +) + +// mockTarget implements RuntimeTarget for testing. +type mockTarget struct { + responses map[string][]byte + errors map[string]error + lastData []byte +} + +func (m *mockTarget) Name() string { + return "test-container" +} + +func (m *mockTarget) Runtime() string { + return "docker" +} + +func (m *mockTarget) ExecProvider( + _ context.Context, + provider string, + operation string, + data []byte, +) ([]byte, error) { + m.lastData = data + key := provider + "/" + operation + + if err, ok := m.errors[key]; ok { + return nil, err + } + + if resp, ok := m.responses[key]; ok { + return resp, nil + } + + return nil, fmt.Errorf("unexpected call: %s", key) +} + +type ContainerProviderPublicTestSuite struct { + suite.Suite +} + +func TestContainerProviderPublicTestSuite(t *testing.T) { + suite.Run(t, new(ContainerProviderPublicTestSuite)) +} + +func (suite *ContainerProviderPublicTestSuite) TestGetHostname() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns hostname", + response: []byte(`"my-container"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "my-container", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "connection refused") + }, + }, + { + name: "unmarshal error", + response: []byte(`not-json`), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "unmarshal") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-hostname": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-hostname"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetHostname(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetOSInfo() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result *orchestrator.HostOSInfo, err error) + }{ + { + name: "returns os info", + response: []byte(`{"Distribution":"ubuntu","Version":"24.04"}`), + validateFunc: func(result *orchestrator.HostOSInfo, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "ubuntu", result.Distribution) + assert.Equal(suite.T(), "24.04", result.Version) + }, + }, + { + name: "exec error", + err: fmt.Errorf("timeout"), + validateFunc: func(_ *orchestrator.HostOSInfo, err error) { + assert.Error(suite.T(), err) + }, + }, + { + name: "unmarshal error", + response: []byte(`{bad`), + validateFunc: func(_ *orchestrator.HostOSInfo, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "unmarshal") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-os-info": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-os-info"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetOSInfo(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetMemStats() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result *orchestrator.MemStats, err error) + }{ + { + name: "parses memory stats", + response: []byte(`{"Total":8192000000,"Available":4096000000,"Free":2048000000,"Cached":1024000000}`), + validateFunc: func(result *orchestrator.MemStats, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), uint64(8192000000), result.Total) + assert.Equal(suite.T(), uint64(4096000000), result.Available) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"mem/get-stats": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["mem/get-stats"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetMemStats(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetLoadStats() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result *orchestrator.LoadStats, err error) + }{ + { + name: "parses load stats", + response: []byte(`{"Load1":0.5,"Load5":0.75,"Load15":1.0}`), + validateFunc: func(result *orchestrator.LoadStats, err error) { + assert.NoError(suite.T(), err) + assert.InDelta(suite.T(), float32(0.5), result.Load1, 0.01) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"load/get-average-stats": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["load/get-average-stats"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetLoadStats(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestExec() { + tests := []struct { + name string + params orchestrator.ExecParams + response []byte + err error + validateFunc func(result *orchestrator.CommandResult, err error, lastData []byte) + }{ + { + name: "runs command with changed true", + params: orchestrator.ExecParams{ + Command: "uname", + Args: []string{"-a"}, + }, + response: []byte(`{"stdout":"Linux container 5.15.0\n","stderr":"","exit_code":0,"duration_ms":5,"changed":true}`), + validateFunc: func(result *orchestrator.CommandResult, err error, lastData []byte) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "Linux container 5.15.0\n", result.Stdout) + assert.True(suite.T(), result.Changed) + + var sent orchestrator.ExecParams + _ = json.Unmarshal(lastData, &sent) + assert.Equal(suite.T(), "uname", sent.Command) + assert.Equal(suite.T(), []string{"-a"}, sent.Args) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"command/exec": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["command/exec"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.Exec(context.Background(), tc.params) + tc.validateFunc(result, err, target.lastData) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestShell() { + tests := []struct { + name string + command string + response []byte + err error + validateFunc func(result *orchestrator.CommandResult, err error) + }{ + { + name: "runs shell command", + command: "echo hello", + response: []byte(`{"stdout":"hello\n","stderr":"","exit_code":0,"duration_ms":2}`), + validateFunc: func(result *orchestrator.CommandResult, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "hello\n", result.Stdout) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"command/shell": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["command/shell"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.Shell(context.Background(), orchestrator.ShellParams{ + Command: tc.command, + }) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestPing() { + tests := []struct { + name string + address string + response []byte + err error + validateFunc func(result *orchestrator.PingResult, err error, lastData []byte) + }{ + { + name: "pings address", + address: "8.8.8.8", + response: []byte(`{"PacketsSent":3,"PacketsReceived":3,"PacketLoss":0}`), + validateFunc: func(result *orchestrator.PingResult, err error, lastData []byte) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), 3, result.PacketsSent) + + var sent orchestrator.PingParams + _ = json.Unmarshal(lastData, &sent) + assert.Equal(suite.T(), "8.8.8.8", sent.Address) + }, + }, + { + name: "exec error", + address: "8.8.8.8", + err: fmt.Errorf("timeout"), + validateFunc: func(_ *orchestrator.PingResult, err error, _ []byte) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"ping/do": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["ping/do"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.Ping(context.Background(), tc.address) + tc.validateFunc(result, err, target.lastData) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetArchitecture() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns architecture", + response: []byte(`"x86_64"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "x86_64", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-architecture": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-architecture"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetArchitecture(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetKernelVersion() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns kernel version", + response: []byte(`"5.15.0-91-generic"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "5.15.0-91-generic", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-kernel-version": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-kernel-version"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetKernelVersion(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetUptime() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result time.Duration, err error) + }{ + { + name: "returns uptime duration", + response: []byte(`3600000000000`), + validateFunc: func(result time.Duration, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), time.Hour, result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ time.Duration, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-uptime": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-uptime"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetUptime(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetFQDN() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns fqdn", + response: []byte(`"web-01.example.com"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "web-01.example.com", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-fqdn": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-fqdn"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetFQDN(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetCPUCount() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result int, err error) + }{ + { + name: "returns cpu count", + response: []byte(`4`), + validateFunc: func(result int, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), 4, result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ int, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-cpu-count": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-cpu-count"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetCPUCount(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetServiceManager() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns service manager", + response: []byte(`"systemd"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "systemd", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-service-manager": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-service-manager"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetServiceManager(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetPackageManager() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result string, err error) + }{ + { + name: "returns package manager", + response: []byte(`"apt"`), + validateFunc: func(result string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "apt", result) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"host/get-package-manager": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["host/get-package-manager"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetPackageManager(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetDiskUsage() { + tests := []struct { + name string + response []byte + err error + validateFunc func(result []orchestrator.DiskUsage, err error) + }{ + { + name: "returns disk usage", + response: []byte(`[{"Name":"/","Total":50000000000,"Used":25000000000,"Free":25000000000}]`), + validateFunc: func(result []orchestrator.DiskUsage, err error) { + assert.NoError(suite.T(), err) + assert.Len(suite.T(), result, 1) + assert.Equal(suite.T(), "/", result[0].Name) + assert.Equal(suite.T(), uint64(50000000000), result[0].Total) + }, + }, + { + name: "exec error", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ []orchestrator.DiskUsage, err error) { + assert.Error(suite.T(), err) + }, + }, + { + name: "unmarshal error", + response: []byte(`not-json`), + validateFunc: func(_ []orchestrator.DiskUsage, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "unmarshal") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"disk/get-local-usage-stats": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["disk/get-local-usage-stats"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetDiskUsage(context.Background()) + tc.validateFunc(result, err) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestGetDNS() { + tests := []struct { + name string + iface string + response []byte + err error + validateFunc func(result *orchestrator.DNSGetResult, err error, lastData []byte) + }{ + { + name: "returns dns config", + iface: "eth0", + response: []byte(`{"DNSServers":["8.8.8.8","8.8.4.4"],"SearchDomains":["example.com"]}`), + validateFunc: func(result *orchestrator.DNSGetResult, err error, lastData []byte) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), []string{"8.8.8.8", "8.8.4.4"}, result.DNSServers) + assert.Equal(suite.T(), []string{"example.com"}, result.SearchDomains) + + var sent orchestrator.DNSGetParams + _ = json.Unmarshal(lastData, &sent) + assert.Equal(suite.T(), "eth0", sent.InterfaceName) + }, + }, + { + name: "exec error", + iface: "eth0", + err: fmt.Errorf("connection refused"), + validateFunc: func(_ *orchestrator.DNSGetResult, err error, _ []byte) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"dns/get-resolv-conf": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["dns/get-resolv-conf"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.GetDNS(context.Background(), tc.iface) + tc.validateFunc(result, err, target.lastData) + }) + } +} + +func (suite *ContainerProviderPublicTestSuite) TestUpdateDNS() { + tests := []struct { + name string + params orchestrator.DNSUpdateParams + response []byte + err error + validateFunc func(result *orchestrator.DNSUpdateResult, err error, lastData []byte) + }{ + { + name: "updates dns config", + params: orchestrator.DNSUpdateParams{ + Servers: []string{"8.8.8.8"}, + SearchDomains: []string{"example.com"}, + InterfaceName: "eth0", + }, + response: []byte(`{"changed":true}`), + validateFunc: func(result *orchestrator.DNSUpdateResult, err error, lastData []byte) { + assert.NoError(suite.T(), err) + assert.True(suite.T(), result.Changed) + + var sent orchestrator.DNSUpdateParams + _ = json.Unmarshal(lastData, &sent) + assert.Equal(suite.T(), []string{"8.8.8.8"}, sent.Servers) + assert.Equal(suite.T(), "eth0", sent.InterfaceName) + }, + }, + { + name: "exec error", + params: orchestrator.DNSUpdateParams{ + Servers: []string{"8.8.8.8"}, + InterfaceName: "eth0", + }, + err: fmt.Errorf("connection refused"), + validateFunc: func(_ *orchestrator.DNSUpdateResult, err error, _ []byte) { + assert.Error(suite.T(), err) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &mockTarget{ + responses: map[string][]byte{"dns/update-resolv-conf": tc.response}, + errors: map[string]error{}, + } + if tc.err != nil { + target.errors["dns/update-resolv-conf"] = tc.err + } + + cp := orchestrator.NewContainerProvider(target) + result, err := cp.UpdateDNS(context.Background(), tc.params) + tc.validateFunc(result, err, target.lastData) + }) + } +} diff --git a/pkg/sdk/orchestrator/container_provider_test.go b/pkg/sdk/orchestrator/container_provider_test.go new file mode 100644 index 00000000..49f20717 --- /dev/null +++ b/pkg/sdk/orchestrator/container_provider_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 John Dewey + +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to +// deal in the Software without restriction, including without limitation the +// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +package orchestrator + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type ContainerProviderTestSuite struct { + suite.Suite +} + +func TestContainerProviderTestSuite(t *testing.T) { + suite.Run(t, new(ContainerProviderTestSuite)) +} + +func (suite *ContainerProviderTestSuite) TestRunMarshalError() { + tests := []struct { + name string + params any + validateFunc func(err error) + }{ + { + name: "returns error when params cannot be marshaled", + params: make(chan int), + validateFunc: func(err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "marshal") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + target := &DockerTarget{ + name: "test", + execFn: func( + _ context.Context, + _ string, + _ []string, + ) (string, string, int, error) { + return `{}`, "", 0, nil + }, + } + + cp := NewContainerProvider(target) + _, err := run[CommandResult]( + context.Background(), + cp, + "test", + "op", + tc.params, + ) + tc.validateFunc(err) + }) + } +} diff --git a/pkg/sdk/orchestrator/deploy.go b/pkg/sdk/orchestrator/deploy.go new file mode 100644 index 00000000..ad508a68 --- /dev/null +++ b/pkg/sdk/orchestrator/deploy.go @@ -0,0 +1,137 @@ +// 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 orchestrator + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" +) + +const ( + defaultGitHubOwner = "retr0h" + defaultGitHubRepo = "osapi" +) + +// releaseAsset represents a single asset in a GitHub release. +type releaseAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// githubRelease represents the GitHub API response for a release. +type githubRelease struct { + TagName string `json:"tag_name"` + Assets []releaseAsset `json:"assets"` +} + +// httpClient allows injection of a custom HTTP client for testing. +var httpClient = http.DefaultClient + +// resolveLatestBinaryURL queries the GitHub API for the latest release +// of the osapi repository and returns the download URL for the binary +// matching the given OS and architecture. +func resolveLatestBinaryURL( + ctx context.Context, + goos string, + goarch string, +) (string, error) { + apiURL := fmt.Sprintf( + "https://api.github.com/repos/%s/%s/releases/latest", + defaultGitHubOwner, + defaultGitHubRepo, + ) + + return resolveFromURL(ctx, apiURL, goos, goarch) +} + +// resolveFromURL fetches a GitHub release JSON from the given URL and +// returns the download URL for the binary matching goos/goarch. +func resolveFromURL( + ctx context.Context, + apiURL string, + goos string, + goarch string, +) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", fmt.Errorf("create request: %w", err) + } + + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("query GitHub releases: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf( + "GitHub releases returned %d (no release published?)", + resp.StatusCode, + ) + } + + var release githubRelease + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return "", fmt.Errorf("decode release response: %w", err) + } + + return matchAsset(release.Assets, goos, goarch) +} + +// matchAsset finds the download URL for the binary matching the given +// OS and architecture in the release assets. +func matchAsset( + assets []releaseAsset, + goos string, + goarch string, +) (string, error) { + suffix := fmt.Sprintf("_%s_%s", goos, goarch) + + for _, a := range assets { + if strings.HasSuffix(a.Name, suffix) { + return a.BrowserDownloadURL, nil + } + } + + return "", fmt.Errorf( + "no osapi binary found for %s/%s in release assets", + goos, + goarch, + ) +} + +// deployScript returns a shell script that ensures curl is available +// and downloads the osapi binary to /osapi inside the container. +func deployScript( + binaryURL string, +) string { + return fmt.Sprintf( + `command -v curl >/dev/null 2>&1 || `+ + `(apt-get update -qq && apt-get install -y -qq ca-certificates curl >/dev/null 2>&1) && `+ + `curl -fsSL '%s' -o /osapi && chmod +x /osapi`, + binaryURL, + ) +} diff --git a/pkg/sdk/orchestrator/deploy_public_test.go b/pkg/sdk/orchestrator/deploy_public_test.go new file mode 100644 index 00000000..f5d61a2b --- /dev/null +++ b/pkg/sdk/orchestrator/deploy_public_test.go @@ -0,0 +1,203 @@ +// 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 orchestrator_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/orchestrator" +) + +type DeployPublicTestSuite struct { + suite.Suite +} + +func TestDeployPublicTestSuite(t *testing.T) { + suite.Run(t, new(DeployPublicTestSuite)) +} + +func (suite *DeployPublicTestSuite) TestPrepare() { + tests := []struct { + name string + binaryURL string + skipPrepare bool + callTwice bool + execStdout string + execStderr string + execCode int + execErr error + validateFunc func(err error, capturedCmds [][]string) + }{ + { + name: "downloads binary from custom URL", + binaryURL: "https://example.com/osapi-linux", + validateFunc: func(err error, capturedCmds [][]string) { + assert.NoError(suite.T(), err) + assert.Len(suite.T(), capturedCmds, 1) + assert.Equal(suite.T(), "sh", capturedCmds[0][0]) + assert.Equal(suite.T(), "-c", capturedCmds[0][1]) + assert.Contains(suite.T(), capturedCmds[0][2], "https://example.com/osapi-linux") + assert.Contains(suite.T(), capturedCmds[0][2], "curl") + assert.Contains(suite.T(), capturedCmds[0][2], "chmod +x /osapi") + }, + }, + { + name: "returns error on non-zero exit", + binaryURL: "https://example.com/osapi-linux", + execStderr: "curl: (22) 404 Not Found", + execCode: 22, + validateFunc: func(err error, _ [][]string) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "deploy osapi binary (exit 22)") + assert.Contains(suite.T(), err.Error(), "404 Not Found") + }, + }, + { + name: "returns error on exec failure", + binaryURL: "https://example.com/osapi-linux", + execErr: assert.AnError, + validateFunc: func(err error, _ [][]string) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "deploy osapi binary") + }, + }, + { + name: "skips preparation when configured", + skipPrepare: true, + validateFunc: func(err error, capturedCmds [][]string) { + assert.NoError(suite.T(), err) + assert.Empty(suite.T(), capturedCmds) + }, + }, + { + name: "executes preparation only once across multiple calls", + binaryURL: "https://example.com/osapi", + callTwice: true, + validateFunc: func(err error, capturedCmds [][]string) { + assert.NoError(suite.T(), err) + assert.Len(suite.T(), capturedCmds, 1) + }, + }, + { + name: "returns error when GitHub release resolution fails", + validateFunc: func(err error, _ [][]string) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "resolve osapi binary") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var capturedCmds [][]string + + execFn := func( + _ context.Context, + _ string, + cmd []string, + ) (string, string, int, error) { + capturedCmds = append(capturedCmds, cmd) + if tc.execErr != nil { + return "", "", -1, tc.execErr + } + + return tc.execStdout, tc.execStderr, tc.execCode, nil + } + + target := orchestrator.NewDockerTarget("web", "ubuntu:24.04", execFn) + if tc.binaryURL != "" { + target.SetBinaryURL(tc.binaryURL) + } + if tc.skipPrepare { + target.SetSkipPrepare(true) + } + + if tc.callTwice { + _ = target.Prepare(context.Background()) + } + + err := target.Prepare(context.Background()) + tc.validateFunc(err, capturedCmds) + }) + } +} + +func (suite *DeployPublicTestSuite) TestExecProvider() { + tests := []struct { + name string + binaryURL string + validateFunc func(result []byte, err error, cmds [][]string) + }{ + { + name: "returns prepare error", + validateFunc: func(result []byte, err error, _ [][]string) { + assert.Error(suite.T(), err) + assert.Nil(suite.T(), result) + assert.Contains(suite.T(), err.Error(), "resolve osapi binary") + }, + }, + { + name: "triggers automatic preparation", + binaryURL: "https://example.com/osapi", + validateFunc: func(_ []byte, err error, cmds [][]string) { + assert.NoError(suite.T(), err) + // First call is the deploy script, second is the provider command. + assert.Len(suite.T(), cmds, 2) + assert.Equal(suite.T(), "sh", cmds[0][0]) + assert.Contains(suite.T(), cmds[0][2], "curl") + assert.Equal(suite.T(), "/osapi", cmds[1][0]) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + var cmds [][]string + + execFn := func( + _ context.Context, + _ string, + cmd []string, + ) (string, string, int, error) { + cmds = append(cmds, cmd) + + return `"ok"`, "", 0, nil + } + + target := orchestrator.NewDockerTarget("web", "ubuntu:24.04", execFn) + if tc.binaryURL != "" { + target.SetBinaryURL(tc.binaryURL) + } + + result, err := target.ExecProvider( + context.Background(), + "host", + "get-hostname", + nil, + ) + tc.validateFunc(result, err, cmds) + }) + } +} diff --git a/pkg/sdk/orchestrator/deploy_test.go b/pkg/sdk/orchestrator/deploy_test.go new file mode 100644 index 00000000..5f18869b --- /dev/null +++ b/pkg/sdk/orchestrator/deploy_test.go @@ -0,0 +1,325 @@ +// 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 orchestrator + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type DeployTestSuite struct { + suite.Suite +} + +func TestDeployTestSuite(t *testing.T) { + suite.Run(t, new(DeployTestSuite)) +} + +func (suite *DeployTestSuite) TestMatchAsset() { + tests := []struct { + name string + assets []releaseAsset + goos string + goarch string + validateFunc func(url string, err error) + }{ + { + name: "matches linux amd64 asset", + assets: []releaseAsset{ + {Name: "osapi_1.0.0_darwin_all", BrowserDownloadURL: "https://example.com/darwin"}, + {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"}, + {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"}, + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(url string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "https://example.com/linux_amd64", url) + }, + }, + { + name: "matches linux arm64 asset", + assets: []releaseAsset{ + {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"}, + {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"}, + }, + goos: "linux", + goarch: "arm64", + validateFunc: func(url string, err error) { + assert.NoError(suite.T(), err) + assert.Equal(suite.T(), "https://example.com/linux_arm64", url) + }, + }, + { + name: "returns error when no matching asset", + assets: []releaseAsset{ + {Name: "osapi_1.0.0_darwin_all", BrowserDownloadURL: "https://example.com/darwin"}, + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "no osapi binary found for linux/amd64") + }, + }, + { + name: "returns error when no assets", + assets: nil, + goos: "linux", + goarch: "amd64", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "no osapi binary found") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url, err := matchAsset(tc.assets, tc.goos, tc.goarch) + tc.validateFunc(url, err) + }) + } +} + +func (suite *DeployTestSuite) TestResolveFromURL() { + tests := []struct { + name string + handler http.HandlerFunc + goos string + goarch string + validateFunc func(url string, err error) + }{ + { + name: "resolves URL from release response", + handler: func(w http.ResponseWriter, _ *http.Request) { + release := githubRelease{ + TagName: "v1.0.0", + Assets: []releaseAsset{ + {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://github.com/retr0h/osapi/releases/download/v1.0.0/osapi_1.0.0_linux_amd64"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release) + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(url string, err error) { + assert.NoError(suite.T(), err) + assert.Contains(suite.T(), url, "osapi_1.0.0_linux_amd64") + }, + }, + { + name: "returns error on 404", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "404") + }, + }, + { + name: "returns error on invalid JSON", + handler: func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{invalid`)) + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "decode") + }, + }, + { + name: "returns error when no matching asset in release", + handler: func(w http.ResponseWriter, _ *http.Request) { + release := githubRelease{ + TagName: "v1.0.0", + Assets: []releaseAsset{ + {Name: "osapi_1.0.0_darwin_all", BrowserDownloadURL: "https://example.com/darwin"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release) + }, + goos: "linux", + goarch: "amd64", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "no osapi binary found") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + server := httptest.NewServer(tc.handler) + defer server.Close() + + url, err := resolveFromURL( + context.Background(), + server.URL, + tc.goos, + tc.goarch, + ) + tc.validateFunc(url, err) + }) + } +} + +func (suite *DeployTestSuite) TestResolveLatestBinaryURL() { + tests := []struct { + name string + handler http.HandlerFunc + validateFunc func(url string, err error) + }{ + { + name: "resolves from GitHub API", + handler: func(w http.ResponseWriter, _ *http.Request) { + release := githubRelease{ + TagName: "v1.0.0", + Assets: []releaseAsset{ + {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/arm64"}, + {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/amd64"}, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(release) + }, + validateFunc: func(url string, err error) { + assert.NoError(suite.T(), err) + assert.NotEmpty(suite.T(), url) + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + server := httptest.NewServer(tc.handler) + defer server.Close() + + original := httpClient + // Create a client that redirects all requests to the test server. + httpClient = &http.Client{ + Transport: &rewriteTransport{ + base: server.Client().Transport, + url: server.URL, + }, + } + defer func() { httpClient = original }() + + url, err := resolveLatestBinaryURL( + context.Background(), + "linux", + "amd64", + ) + tc.validateFunc(url, err) + }) + } +} + +// rewriteTransport redirects all requests to a test server URL. +type rewriteTransport struct { + base http.RoundTripper + url string +} + +func (t *rewriteTransport) RoundTrip( + req *http.Request, +) (*http.Response, error) { + req = req.Clone(req.Context()) + req.URL.Scheme = "http" + req.URL.Host = t.url[len("http://"):] + + return t.base.RoundTrip(req) +} + +func (suite *DeployTestSuite) TestResolveFromURLHTTPError() { + tests := []struct { + name string + apiURL string + validateFunc func(url string, err error) + }{ + { + name: "returns error on connection failure", + apiURL: "http://127.0.0.1:0/invalid", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "query GitHub releases") + }, + }, + { + name: "returns error on invalid URL", + apiURL: "http://invalid\x00url", + validateFunc: func(_ string, err error) { + assert.Error(suite.T(), err) + assert.Contains(suite.T(), err.Error(), "create request") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + url, err := resolveFromURL( + context.Background(), + tc.apiURL, + "linux", + "amd64", + ) + tc.validateFunc(url, err) + }) + } +} + +func (suite *DeployTestSuite) TestDeployScript() { + tests := []struct { + name string + binaryURL string + validateFunc func(script string) + }{ + { + name: "generates valid download script", + binaryURL: "https://example.com/osapi", + validateFunc: func(script string) { + assert.Contains(suite.T(), script, "curl") + assert.Contains(suite.T(), script, "https://example.com/osapi") + assert.Contains(suite.T(), script, "-o /osapi") + assert.Contains(suite.T(), script, "chmod +x /osapi") + }, + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + script := deployScript(tc.binaryURL) + tc.validateFunc(script) + }) + } +} diff --git a/pkg/sdk/orchestrator/docker_target.go b/pkg/sdk/orchestrator/docker_target.go index 6831d1b1..ffd3c8d5 100644 --- a/pkg/sdk/orchestrator/docker_target.go +++ b/pkg/sdk/orchestrator/docker_target.go @@ -23,6 +23,8 @@ package orchestrator import ( "context" "fmt" + "runtime" + "sync" ) // ExecFn executes a command inside a container and returns stdout/stderr/exit code. @@ -33,10 +35,17 @@ type ExecFn func( ) (stdout, stderr string, exitCode int, err error) // DockerTarget implements RuntimeTarget for Docker containers. +// It automatically deploys the osapi binary into the container +// on first use via Prepare. type DockerTarget struct { - name string - image string - execFn ExecFn + name string + image string + execFn ExecFn + binaryURL string + skipPrepare bool + + prepareOnce sync.Once + prepareErr error } // NewDockerTarget creates a new Docker runtime target. @@ -67,13 +76,83 @@ func (t *DockerTarget) Image() string { return t.image } -// ExecProvider runs a provider operation inside this container via docker exec. +// SetBinaryURL overrides the GitHub release URL for the osapi binary. +func (t *DockerTarget) SetBinaryURL( + url string, +) { + t.binaryURL = url +} + +// SetSkipPrepare disables automatic binary deployment. +func (t *DockerTarget) SetSkipPrepare( + skip bool, +) { + t.skipPrepare = skip +} + +// Prepare ensures the osapi binary is deployed inside the container. +// It resolves the binary URL (from GitHub releases or a custom URL), +// downloads it inside the container, and makes it executable. +// Subsequent calls are no-ops. +func (t *DockerTarget) Prepare( + ctx context.Context, +) error { + if t.skipPrepare { + return nil + } + + t.prepareOnce.Do(func() { + t.prepareErr = t.doPrepare(ctx) + }) + + return t.prepareErr +} + +// doPrepare performs the actual binary deployment. +func (t *DockerTarget) doPrepare( + ctx context.Context, +) error { + url := t.binaryURL + if url == "" { + var err error + + url, err = resolveLatestBinaryURL(ctx, "linux", runtime.GOARCH) + if err != nil { + return fmt.Errorf("resolve osapi binary: %w", err) + } + } + + script := deployScript(url) + + _, stderr, exitCode, err := t.execFn( + ctx, + t.name, + []string{"sh", "-c", script}, + ) + if err != nil { + return fmt.Errorf("deploy osapi binary: %w", err) + } + + if exitCode != 0 { + return fmt.Errorf("deploy osapi binary (exit %d): %s", exitCode, stderr) + } + + return nil +} + +// ExecProvider runs a provider operation inside this container via +// docker exec. On first call it automatically deploys the osapi binary +// unless WithOSAPIBinarySkip was set. func (t *DockerTarget) ExecProvider( ctx context.Context, provider string, operation string, data []byte, ) ([]byte, error) { + if err := t.Prepare(ctx); err != nil { + return nil, err + } + cmd := []string{"/osapi", "provider", "run", provider, operation} if len(data) > 0 { cmd = append(cmd, "--data", string(data)) diff --git a/pkg/sdk/orchestrator/docker_target_public_test.go b/pkg/sdk/orchestrator/docker_target_public_test.go index 39d53acd..c211d14f 100644 --- a/pkg/sdk/orchestrator/docker_target_public_test.go +++ b/pkg/sdk/orchestrator/docker_target_public_test.go @@ -175,6 +175,8 @@ func (s *DockerTargetPublicTestSuite) TestExecProvider() { } target := orchestrator.NewDockerTarget("web", "ubuntu:24.04", execFn) + target.SetSkipPrepare(true) + result, err := target.ExecProvider( context.Background(), tt.provider, diff --git a/pkg/sdk/orchestrator/options.go b/pkg/sdk/orchestrator/options.go index b2cbea8f..8e579ef8 100644 --- a/pkg/sdk/orchestrator/options.go +++ b/pkg/sdk/orchestrator/options.go @@ -98,6 +98,8 @@ type PlanConfig struct { OnErrorStrategy ErrorStrategy Hooks *Hooks DockerExecFn ExecFn + DockerBinaryURL string + DockerSkipDeploy bool } // PlanOption is a functional option for NewPlan. @@ -130,3 +132,23 @@ func WithDockerExecFn( cfg.DockerExecFn = fn } } + +// WithOSAPIBinaryURL overrides the default GitHub release URL for the +// osapi binary that gets deployed into Docker containers. Use this for +// custom builds, mirrors, or pre-release binaries. +func WithOSAPIBinaryURL( + url string, +) PlanOption { + return func(cfg *PlanConfig) { + cfg.DockerBinaryURL = url + } +} + +// WithOSAPIBinarySkip disables automatic binary deployment into Docker +// containers. Use this when the osapi binary is already baked into the +// container image. +func WithOSAPIBinarySkip() PlanOption { + return func(cfg *PlanConfig) { + cfg.DockerSkipDeploy = true + } +} diff --git a/pkg/sdk/orchestrator/options_public_test.go b/pkg/sdk/orchestrator/options_public_test.go index 803428d8..e6b01dd0 100644 --- a/pkg/sdk/orchestrator/options_public_test.go +++ b/pkg/sdk/orchestrator/options_public_test.go @@ -142,3 +142,43 @@ func (s *OptionsPublicTestSuite) TestPlanOption() { }) } } + +func (s *OptionsPublicTestSuite) TestWithOSAPIBinaryURL() { + tests := []struct { + name string + url string + }{ + { + name: "sets binary URL", + url: "https://example.com/osapi", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + cfg := &orchestrator.PlanConfig{} + opt := orchestrator.WithOSAPIBinaryURL(tt.url) + opt(cfg) + s.Equal(tt.url, cfg.DockerBinaryURL) + }) + } +} + +func (s *OptionsPublicTestSuite) TestWithOSAPIBinarySkip() { + tests := []struct { + name string + }{ + { + name: "sets skip deploy flag", + }, + } + + for _, tt := range tests { + s.Run(tt.name, func() { + cfg := &orchestrator.PlanConfig{} + opt := orchestrator.WithOSAPIBinarySkip() + opt(cfg) + s.True(cfg.DockerSkipDeploy) + }) + } +} diff --git a/pkg/sdk/orchestrator/plan.go b/pkg/sdk/orchestrator/plan.go index 91427529..3a6d4215 100644 --- a/pkg/sdk/orchestrator/plan.go +++ b/pkg/sdk/orchestrator/plan.go @@ -10,10 +10,12 @@ import ( // Plan is a DAG of tasks with dependency edges. type Plan struct { - client *osapiclient.Client - tasks []*Task - config PlanConfig - dockerExecFn ExecFn + client *osapiclient.Client + tasks []*Task + config PlanConfig + dockerExecFn ExecFn + dockerBinaryURL string + dockerSkipDeploy bool } // NewPlan creates a new plan bound to an OSAPI client. @@ -30,9 +32,11 @@ func NewPlan( } return &Plan{ - client: client, - config: cfg, - dockerExecFn: cfg.DockerExecFn, + client: client, + config: cfg, + dockerExecFn: cfg.DockerExecFn, + dockerBinaryURL: cfg.DockerBinaryURL, + dockerSkipDeploy: cfg.DockerSkipDeploy, } } diff --git a/pkg/sdk/orchestrator/plan_in.go b/pkg/sdk/orchestrator/plan_in.go index c03994b2..d31845c7 100644 --- a/pkg/sdk/orchestrator/plan_in.go +++ b/pkg/sdk/orchestrator/plan_in.go @@ -36,7 +36,10 @@ func (p *Plan) In( } } -// Docker creates a DockerTarget bound to this plan. +// Docker creates a DockerTarget bound to this plan. The target +// automatically deploys the osapi binary into the container on first +// provider call. Use WithOSAPIBinaryURL to override the download +// source, or WithOSAPIBinarySkip if the binary is pre-installed. // Panics if no ExecFn was provided via WithDockerExecFn option. func (p *Plan) Docker( name string, @@ -46,7 +49,11 @@ func (p *Plan) Docker( panic("orchestrator: Plan.Docker() called without WithDockerExecFn option") } - return NewDockerTarget(name, image, p.dockerExecFn) + t := NewDockerTarget(name, image, p.dockerExecFn) + t.binaryURL = p.dockerBinaryURL + t.skipPrepare = p.dockerSkipDeploy + + return t } // Target returns the runtime target for this scoped plan. diff --git a/pkg/sdk/platform/platform.go b/pkg/sdk/platform/platform.go new file mode 100644 index 00000000..84e35689 --- /dev/null +++ b/pkg/sdk/platform/platform.go @@ -0,0 +1,50 @@ +// 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 platform provides cross-platform detection for OSAPI providers. +// Both the CLI (provider run) and the agent use this package to select +// the correct provider variant (ubuntu, darwin, or generic linux). +package platform + +import ( + "strings" + + "github.com/shirou/gopsutil/v4/host" +) + +// HostInfoFn is the function used to retrieve host information. +// Override in tests to simulate different platforms. +var HostInfoFn = host.Info + +// Detect returns the normalized platform name for provider selection. +// Returns "ubuntu", "darwin", or "" (generic linux/unknown). +func Detect() string { + info, _ := HostInfoFn() + if info == nil { + return "" + } + + platform := strings.ToLower(info.Platform) + if platform == "" && strings.ToLower(info.OS) == "darwin" { + return "darwin" + } + + return platform +} diff --git a/pkg/sdk/platform/platform_public_test.go b/pkg/sdk/platform/platform_public_test.go new file mode 100644 index 00000000..01430c63 --- /dev/null +++ b/pkg/sdk/platform/platform_public_test.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 platform_test + +import ( + "fmt" + "testing" + + "github.com/shirou/gopsutil/v4/host" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" + + "github.com/retr0h/osapi/pkg/sdk/platform" +) + +type PlatformPublicTestSuite struct { + suite.Suite +} + +func TestPlatformPublicTestSuite(t *testing.T) { + suite.Run(t, new(PlatformPublicTestSuite)) +} + +func (suite *PlatformPublicTestSuite) TestDetect() { + tests := []struct { + name string + infoFn func() (*host.InfoStat, error) + expected string + }{ + { + name: "returns ubuntu when platform is Ubuntu", + infoFn: func() (*host.InfoStat, error) { + return &host.InfoStat{Platform: "Ubuntu"}, nil + }, + expected: "ubuntu", + }, + { + name: "returns darwin when platform is empty and OS is darwin", + infoFn: func() (*host.InfoStat, error) { + return &host.InfoStat{Platform: "", OS: "darwin"}, nil + }, + expected: "darwin", + }, + { + name: "returns centos for centos platform", + infoFn: func() (*host.InfoStat, error) { + return &host.InfoStat{Platform: "centos"}, nil + }, + expected: "centos", + }, + { + name: "returns empty string when info is nil", + infoFn: func() (*host.InfoStat, error) { + return nil, fmt.Errorf("no host info") + }, + expected: "", + }, + { + name: "returns empty string when platform and OS are empty", + infoFn: func() (*host.InfoStat, error) { + return &host.InfoStat{}, nil + }, + expected: "", + }, + } + + for _, tc := range tests { + suite.Run(tc.name, func() { + original := platform.HostInfoFn + defer func() { platform.HostInfoFn = original }() + + platform.HostInfoFn = tc.infoFn + result := platform.Detect() + + assert.Equal(suite.T(), tc.expected, result) + }) + } +} From 242ee028f3bf103d62e38fbfe9655d66c860ff32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 12 Mar 2026 09:44:11 -0700 Subject: [PATCH 2/4] style(sdk): apply linter formatting fixes Co-Authored-By: Claude JIRA: None --- cmd/provider_run.go | 1 - examples/sdk/orchestrator/features/container-targeting.go | 6 ++++-- pkg/sdk/orchestrator/options.go | 8 ++++---- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cmd/provider_run.go b/cmd/provider_run.go index 5ed89a42..08129fcf 100644 --- a/cmd/provider_run.go +++ b/cmd/provider_run.go @@ -86,4 +86,3 @@ func init() { providerCmd.AddCommand(providerRunCmd) rootCmd.AddCommand(providerCmd) } - diff --git a/examples/sdk/orchestrator/features/container-targeting.go b/examples/sdk/orchestrator/features/container-targeting.go index 38434030..2d94fc4d 100644 --- a/examples/sdk/orchestrator/features/container-targeting.go +++ b/examples/sdk/orchestrator/features/container-targeting.go @@ -55,8 +55,10 @@ import ( "github.com/retr0h/osapi/pkg/sdk/orchestrator" ) -const containerName = "example-provider-container" -const containerImage = "ubuntu:24.04" +const ( + containerName = "example-provider-container" + containerImage = "ubuntu:24.04" +) func ptr(s string) *string { return &s } diff --git a/pkg/sdk/orchestrator/options.go b/pkg/sdk/orchestrator/options.go index 8e579ef8..652d6190 100644 --- a/pkg/sdk/orchestrator/options.go +++ b/pkg/sdk/orchestrator/options.go @@ -95,10 +95,10 @@ type Hooks struct { // PlanConfig holds plan-level configuration. type PlanConfig struct { - OnErrorStrategy ErrorStrategy - Hooks *Hooks - DockerExecFn ExecFn - DockerBinaryURL string + OnErrorStrategy ErrorStrategy + Hooks *Hooks + DockerExecFn ExecFn + DockerBinaryURL string DockerSkipDeploy bool } From fb184554b9868db855a6e1b75741f9497cf94809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 12 Mar 2026 10:47:58 -0600 Subject: [PATCH 3/4] style: wrap long lines per linter rules Co-Authored-By: Claude JIRA: None --- .../features/container-targeting.go | 6 ++- .../container_provider_public_test.go | 24 +++++++---- pkg/sdk/orchestrator/deploy_test.go | 40 +++++++++++++++---- 3 files changed, 53 insertions(+), 17 deletions(-) diff --git a/examples/sdk/orchestrator/features/container-targeting.go b/examples/sdk/orchestrator/features/container-targeting.go index 2d94fc4d..1e809c62 100644 --- a/examples/sdk/orchestrator/features/container-targeting.go +++ b/examples/sdk/orchestrator/features/container-targeting.go @@ -547,7 +547,11 @@ func main() { log.Fatal(err) } - fmt.Printf("\n=== Summary: %s in %s ===\n", report.Summary(), report.Duration.Truncate(time.Millisecond)) + fmt.Printf( + "\n=== Summary: %s in %s ===\n", + report.Summary(), + report.Duration.Truncate(time.Millisecond), + ) } // findProjectRoot walks up from the current directory to find go.mod. diff --git a/pkg/sdk/orchestrator/container_provider_public_test.go b/pkg/sdk/orchestrator/container_provider_public_test.go index 2530d476..d695c3b9 100644 --- a/pkg/sdk/orchestrator/container_provider_public_test.go +++ b/pkg/sdk/orchestrator/container_provider_public_test.go @@ -184,8 +184,10 @@ func (suite *ContainerProviderPublicTestSuite) TestGetMemStats() { validateFunc func(result *orchestrator.MemStats, err error) }{ { - name: "parses memory stats", - response: []byte(`{"Total":8192000000,"Available":4096000000,"Free":2048000000,"Cached":1024000000}`), + name: "parses memory stats", + response: []byte( + `{"Total":8192000000,"Available":4096000000,"Free":2048000000,"Cached":1024000000}`, + ), validateFunc: func(result *orchestrator.MemStats, err error) { assert.NoError(suite.T(), err) assert.Equal(suite.T(), uint64(8192000000), result.Total) @@ -259,7 +261,9 @@ func (suite *ContainerProviderPublicTestSuite) TestExec() { Command: "uname", Args: []string{"-a"}, }, - response: []byte(`{"stdout":"Linux container 5.15.0\n","stderr":"","exit_code":0,"duration_ms":5,"changed":true}`), + response: []byte( + `{"stdout":"Linux container 5.15.0\n","stderr":"","exit_code":0,"duration_ms":5,"changed":true}`, + ), validateFunc: func(result *orchestrator.CommandResult, err error, lastData []byte) { assert.NoError(suite.T(), err) assert.Equal(suite.T(), "Linux container 5.15.0\n", result.Stdout) @@ -671,8 +675,10 @@ func (suite *ContainerProviderPublicTestSuite) TestGetDiskUsage() { validateFunc func(result []orchestrator.DiskUsage, err error) }{ { - name: "returns disk usage", - response: []byte(`[{"Name":"/","Total":50000000000,"Used":25000000000,"Free":25000000000}]`), + name: "returns disk usage", + response: []byte( + `[{"Name":"/","Total":50000000000,"Used":25000000000,"Free":25000000000}]`, + ), validateFunc: func(result []orchestrator.DiskUsage, err error) { assert.NoError(suite.T(), err) assert.Len(suite.T(), result, 1) @@ -723,9 +729,11 @@ func (suite *ContainerProviderPublicTestSuite) TestGetDNS() { validateFunc func(result *orchestrator.DNSGetResult, err error, lastData []byte) }{ { - name: "returns dns config", - iface: "eth0", - response: []byte(`{"DNSServers":["8.8.8.8","8.8.4.4"],"SearchDomains":["example.com"]}`), + name: "returns dns config", + iface: "eth0", + response: []byte( + `{"DNSServers":["8.8.8.8","8.8.4.4"],"SearchDomains":["example.com"]}`, + ), validateFunc: func(result *orchestrator.DNSGetResult, err error, lastData []byte) { assert.NoError(suite.T(), err) assert.Equal(suite.T(), []string{"8.8.8.8", "8.8.4.4"}, result.DNSServers) diff --git a/pkg/sdk/orchestrator/deploy_test.go b/pkg/sdk/orchestrator/deploy_test.go index 5f18869b..2d89c2cf 100644 --- a/pkg/sdk/orchestrator/deploy_test.go +++ b/pkg/sdk/orchestrator/deploy_test.go @@ -51,8 +51,14 @@ func (suite *DeployTestSuite) TestMatchAsset() { name: "matches linux amd64 asset", assets: []releaseAsset{ {Name: "osapi_1.0.0_darwin_all", BrowserDownloadURL: "https://example.com/darwin"}, - {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"}, - {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"}, + { + Name: "osapi_1.0.0_linux_amd64", + BrowserDownloadURL: "https://example.com/linux_amd64", + }, + { + Name: "osapi_1.0.0_linux_arm64", + BrowserDownloadURL: "https://example.com/linux_arm64", + }, }, goos: "linux", goarch: "amd64", @@ -64,8 +70,14 @@ func (suite *DeployTestSuite) TestMatchAsset() { { name: "matches linux arm64 asset", assets: []releaseAsset{ - {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/linux_amd64"}, - {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/linux_arm64"}, + { + Name: "osapi_1.0.0_linux_amd64", + BrowserDownloadURL: "https://example.com/linux_amd64", + }, + { + Name: "osapi_1.0.0_linux_arm64", + BrowserDownloadURL: "https://example.com/linux_arm64", + }, }, goos: "linux", goarch: "arm64", @@ -120,7 +132,10 @@ func (suite *DeployTestSuite) TestResolveFromURL() { release := githubRelease{ TagName: "v1.0.0", Assets: []releaseAsset{ - {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://github.com/retr0h/osapi/releases/download/v1.0.0/osapi_1.0.0_linux_amd64"}, + { + Name: "osapi_1.0.0_linux_amd64", + BrowserDownloadURL: "https://github.com/retr0h/osapi/releases/download/v1.0.0/osapi_1.0.0_linux_amd64", + }, }, } w.Header().Set("Content-Type", "application/json") @@ -163,7 +178,10 @@ func (suite *DeployTestSuite) TestResolveFromURL() { release := githubRelease{ TagName: "v1.0.0", Assets: []releaseAsset{ - {Name: "osapi_1.0.0_darwin_all", BrowserDownloadURL: "https://example.com/darwin"}, + { + Name: "osapi_1.0.0_darwin_all", + BrowserDownloadURL: "https://example.com/darwin", + }, }, } w.Header().Set("Content-Type", "application/json") @@ -206,8 +224,14 @@ func (suite *DeployTestSuite) TestResolveLatestBinaryURL() { release := githubRelease{ TagName: "v1.0.0", Assets: []releaseAsset{ - {Name: "osapi_1.0.0_linux_arm64", BrowserDownloadURL: "https://example.com/arm64"}, - {Name: "osapi_1.0.0_linux_amd64", BrowserDownloadURL: "https://example.com/amd64"}, + { + Name: "osapi_1.0.0_linux_arm64", + BrowserDownloadURL: "https://example.com/arm64", + }, + { + Name: "osapi_1.0.0_linux_amd64", + BrowserDownloadURL: "https://example.com/amd64", + }, }, } w.Header().Set("Content-Type", "application/json") From 66920562a14109374b7accc5a0e43cad345c80e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D7=A0=CF=85=CE=B1=CE=B7=20=D7=A0=CF=85=CE=B1=CE=B7=D1=95?= =?UTF-8?q?=CF=83=CE=B7?= Date: Thu, 12 Mar 2026 13:51:00 -0600 Subject: [PATCH 4/4] fix(sdk): handle resp.Body.Close error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap defer resp.Body.Close() to explicitly discard the error return value, satisfying errcheck linter. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- pkg/sdk/orchestrator/deploy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sdk/orchestrator/deploy.go b/pkg/sdk/orchestrator/deploy.go index ad508a68..72fc2226 100644 --- a/pkg/sdk/orchestrator/deploy.go +++ b/pkg/sdk/orchestrator/deploy.go @@ -84,7 +84,7 @@ func resolveFromURL( if err != nil { return "", fmt.Errorf("query GitHub releases: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", fmt.Errorf(