diff --git a/cmd/provider_run.go b/cmd/provider_run.go index 05582c65..08129fcf 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) @@ -85,10 +86,3 @@ func init() { providerCmd.AddCommand(providerRunCmd) 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..1e809c62 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,21 @@ 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" + containerImage = "ubuntu:24.04" +) + func ptr(s string) *string { return &s } func main() { @@ -62,21 +80,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 +135,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 +150,7 @@ func main() { }, ) - // Create the container. autoStart := true - create := plan.TaskFunc( "create-container", func( @@ -109,13 +158,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 +178,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 }, ) - execInside.DependsOn(create) + 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 + }, + ) + 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 +516,7 @@ func main() { _, err := c.Container.Remove( ctx, target, - "example-container", + containerName, &gen.DeleteNodeContainerByIDParams{Force: &force}, ) if err != nil { @@ -179,12 +526,48 @@ 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..d695c3b9 --- /dev/null +++ b/pkg/sdk/orchestrator/container_provider_public_test.go @@ -0,0 +1,828 @@ +// 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..72fc2226 --- /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 func() { _ = 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..2d89c2cf --- /dev/null +++ b/pkg/sdk/orchestrator/deploy_test.go @@ -0,0 +1,349 @@ +// 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..652d6190 100644 --- a/pkg/sdk/orchestrator/options.go +++ b/pkg/sdk/orchestrator/options.go @@ -95,9 +95,11 @@ type Hooks struct { // PlanConfig holds plan-level configuration. type PlanConfig struct { - OnErrorStrategy ErrorStrategy - Hooks *Hooks - DockerExecFn ExecFn + 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) + }) + } +}