diff --git a/client/README.md b/client/README.md new file mode 100644 index 0000000..360eccd --- /dev/null +++ b/client/README.md @@ -0,0 +1,414 @@ +# Castletown Client Library + +A comprehensive Go client library for interacting with the mycastletown sandboxed code execution service. This library provides both HTTP REST and gRPC clients with a unified interface. + +## Features + +- **Dual Protocol Support**: Connect via HTTP REST or gRPC +- **Unified Interface**: Same API regardless of protocol +- **Fluent Builder API**: Easy-to-use builder patterns for constructing requests +- **Type Safety**: Strongly-typed request/response structures +- **Context Support**: Full support for context-based cancellation and timeouts +- **Helper Functions**: Pre-built patterns for common use cases (compile-and-run, etc.) + +## Installation + +```bash +go get github.com/joshjms/castletown/client +``` + +## Quick Start + +### HTTP Client + +```go +package main + +import ( + "context" + "log" + "github.com/joshjms/castletown/client" +) + +func main() { + // Create HTTP client + c, err := client.NewHTTPClient("http://localhost:8000", nil) + if err != nil { + log.Fatal(err) + } + defer c.Close() + + // Build and execute request + req := client.NewRequest(). + AddFile("hello.txt", "Hello, World!"). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("/bin/cat", "hello.txt"). + WithFiles("hello.txt") + }). + Build() + + resp, err := c.Execute(context.Background(), req) + if err != nil { + log.Fatal(err) + } + + log.Printf("Output: %s", resp.Reports[0].Stdout) +} +``` + +### gRPC Client + +```go +// Create gRPC client +opts := &client.ClientOptions{ + GRPCOptions: &client.GRPCOptions{ + Insecure: true, + }, +} +c, err := client.NewGRPCClient("localhost:8001", opts) +if err != nil { + log.Fatal(err) +} +defer c.Close() + +// Use the same API as HTTP client +``` + +## Core Concepts + +### Client Interface + +The `Client` interface provides three main methods: + +```go +type Client interface { + // Execute submits a job and returns results + Execute(ctx context.Context, req *ExecRequest) (*ExecResponse, error) + + // Done marks a job as complete (cleanup) + Done(ctx context.Context, jobID string) error + + // Close releases client resources + Close() error +} +``` + +### Execution Request + +An execution request consists of: +- **Files**: Files to create in the sandbox +- **Steps**: Sequential processes to execute +- **ID**: Optional job identifier (auto-generated if not provided) + +```go +type ExecRequest struct { + ID string + Files []File + Steps []Process +} +``` + +### Process Configuration + +Each process step supports: +- **Image**: Container image name +- **Command**: Command and arguments +- **Stdin**: Standard input +- **Resource Limits**: Memory, time, and process limits +- **Files**: Which files to make available +- **Persist**: Which files to keep for next step + +```go +type Process struct { + Image string + Cmd []string + Stdin string + MemoryLimitMB int64 + TimeLimitMs uint64 + ProcLimit int64 + Files []string + Persist []string +} +``` + +## Builder API + +### RequestBuilder + +The fluent builder API makes it easy to construct requests: + +```go +req := client.NewRequest(). + WithID("my-job-id"). + AddFile("source.cpp", cppCode). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("g++", "source.cpp", "-o", "program"). + WithFiles("source.cpp"). + WithPersist("program"). + WithMemoryLimit(512). + WithTimeLimit(10000) + }). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("./program"). + WithFiles("program"). + WithStdin("test input"). + WithMemoryLimit(256). + WithTimeLimit(5000) + }). + Build() +``` + +### ProcessBuilder + +Configure individual process steps: + +```go +p.WithImage("gcc:15-bookworm"). // Container image + WithCommand("g++", "main.cpp"). // Command and args + WithStdin("input data"). // Standard input + WithMemoryLimit(512). // Memory limit (MB) + WithTimeLimit(10000). // Time limit (ms) + WithProcLimit(10). // Process limit + WithFiles("main.cpp", "header.h"). // Available files + WithPersist("main", "output.txt") // Files to persist +``` + +## Helper Functions + +### SimpleExecRequest + +For simple single-step executions: + +```go +req := client.SimpleExecRequest( + "gcc:15-bookworm", // Image + []string{"/bin/cat", "file.txt"}, // Command + map[string]string{ // Files + "file.txt": "content", + }, +) +``` + +### CompileAndRunRequest + +For compile-then-run workflows: + +```go +req := client.CompileAndRunRequest( + "gcc:15-bookworm", // Compile image + []string{"g++", "main.cpp", "-o", "main"}, // Compile command + "gcc:15-bookworm", // Run image + []string{"./main"}, // Run command + map[string]string{"main.cpp": sourceCode}, // Source files + []string{"main"}, // Compiled outputs + "test input", // Stdin for run +) +``` + +## Response Handling + +### Execution Response + +```go +type ExecResponse struct { + ID string // Job ID + Reports []Report // One per step +} +``` + +### Report Structure + +Each report contains: + +```go +type Report struct { + Status Status // Execution status + ExitCode int32 // Process exit code + Signal int32 // Termination signal (-1 if normal) + Stdout string // Standard output + Stderr string // Standard error + CPUTime uint64 // CPU time (nanoseconds) + Memory uint64 // Peak memory (bytes) + WallTime int64 // Wall time (milliseconds) + StartAt int64 // Start timestamp (ns) + FinishAt int64 // Finish timestamp (ns) +} +``` + +### Status Codes + +```go +const ( + StatusOK // Successful execution + StatusRuntimeError // Program error + StatusTimeLimitExceeded // Time limit hit + StatusMemoryLimitExceeded // Memory limit hit + StatusOutputLimitExceeded // Output too large + StatusTerminated // Process terminated + StatusUnknown // Unknown error + StatusSkipped // Step skipped +) +``` + +## Configuration Options + +### HTTP Client Options + +```go +opts := &client.ClientOptions{ + Address: "http://localhost:8000", + Timeout: 30 * time.Second, +} +c, err := client.NewHTTPClient("", opts) +``` + +### gRPC Client Options + +```go +opts := &client.ClientOptions{ + Address: "localhost:8001", + Timeout: 30 * time.Second, + GRPCOptions: &client.GRPCOptions{ + Insecure: true, // Disable TLS + MaxMessageSize: 4 * 1024 * 1024, // 4MB max message + }, +} +c, err := client.NewGRPCClient("", opts) +``` + +## Examples + +See the `examples/` directory for complete working examples: + +- **basic_http/**: Simple HTTP client example +- **basic_grpc/**: Simple gRPC client example +- **compile_and_run/**: Compile and run C++ code +- **advanced/**: Multi-step execution with resource limits + +### Running Examples + +```bash +# Start the mycastletown server first +cd /path/to/mycastletown +go run main.go server + +# Run examples +cd client/examples/basic_http +go run main.go + +cd ../compile_and_run +go run main.go + +cd ../advanced +go run main.go +``` + +## Error Handling + +Always check for errors and handle them appropriately: + +```go +resp, err := c.Execute(ctx, req) +if err != nil { + log.Fatalf("Execution failed: %v", err) +} + +for i, report := range resp.Reports { + if report.Status != client.StatusOK { + log.Printf("Step %d failed: %s", i, report.Status) + log.Printf("Stderr: %s", report.Stderr) + } +} +``` + +## Context and Timeouts + +Use contexts for cancellation and timeouts: + +```go +// With timeout +ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +defer cancel() + +resp, err := c.Execute(ctx, req) + +// With cancellation +ctx, cancel := context.WithCancel(context.Background()) +go func() { + time.Sleep(10 * time.Second) + cancel() // Cancel after 10 seconds +}() + +resp, err := c.Execute(ctx, req) +``` + +## Resource Cleanup + +Always clean up resources: + +```go +// Close client when done +defer c.Close() + +// Mark jobs as done for cleanup +resp, err := c.Execute(ctx, req) +if err == nil { + defer c.Done(ctx, resp.ID) +} +``` + +## Best Practices + +1. **Use context timeouts**: Always set appropriate timeouts +2. **Clean up jobs**: Call `Done()` after execution to free server resources +3. **Close clients**: Always `defer c.Close()` after creating clients +4. **Check status codes**: Verify `report.Status` for each step +5. **Handle errors gracefully**: Check stderr for error details +6. **Set resource limits**: Use `WithMemoryLimit()` and `WithTimeLimit()` to prevent runaway processes +7. **Persist selectively**: Only persist files needed for subsequent steps +8. **Use builders**: Leverage the fluent builder API for cleaner code + +## Troubleshooting + +### Connection Issues + +- Verify server is running: `curl http://localhost:8000/exec` +- Check firewall settings +- For gRPC, ensure port 8001 is accessible + +### Execution Failures + +- Check `report.Stderr` for error messages +- Verify container image exists on server +- Ensure files are properly included with `WithFiles()` +- Check resource limits are sufficient + +### Image Not Found + +Images must be available on the server. See mycastletown docs for image preparation: + +```bash +# On the server +skopeo copy docker://gcc:15-bookworm oci:images/gcc:15-bookworm +umoci unpack --image images/gcc:15-bookworm rootfs/gcc:15-bookworm +``` + +## API Reference + +Full API documentation is available via godoc: + +```bash +godoc -http=:6060 +# Visit http://localhost:6060/pkg/github.com/joshjms/castletown/client/ +``` + +## License + +Same as mycastletown main project. + +## Contributing + +Contributions are welcome! Please submit issues and pull requests to the main mycastletown repository. diff --git a/client/builder.go b/client/builder.go new file mode 100644 index 0000000..9a66e61 --- /dev/null +++ b/client/builder.go @@ -0,0 +1,187 @@ +package client + +// RequestBuilder provides a fluent API for building execution requests. +type RequestBuilder struct { + req *ExecRequest +} + +// NewRequest creates a new RequestBuilder. +func NewRequest() *RequestBuilder { + return &RequestBuilder{ + req: &ExecRequest{ + Files: []File{}, + Steps: []Process{}, + }, + } +} + +// WithID sets the job ID for the request. +func (b *RequestBuilder) WithID(id string) *RequestBuilder { + b.req.ID = id + return b +} + +// AddFile adds a file to the request. +func (b *RequestBuilder) AddFile(name, content string) *RequestBuilder { + b.req.Files = append(b.req.Files, File{ + Name: name, + Content: content, + }) + return b +} + +// AddStep adds a process/step to the request using a ProcessBuilder. +func (b *RequestBuilder) AddStep(fn func(*ProcessBuilder)) *RequestBuilder { + pb := NewProcess() + fn(pb) + b.req.Steps = append(b.req.Steps, pb.Build()) + return b +} + +// Build returns the constructed ExecRequest. +func (b *RequestBuilder) Build() *ExecRequest { + return b.req +} + +// ProcessBuilder provides a fluent API for building process specifications. +type ProcessBuilder struct { + proc Process +} + +// NewProcess creates a new ProcessBuilder. +func NewProcess() *ProcessBuilder { + return &ProcessBuilder{ + proc: Process{ + Cmd: []string{}, + Files: []string{}, + Persist: []string{}, + }, + } +} + +// WithImage sets the container image for the process. +func (p *ProcessBuilder) WithImage(image string) *ProcessBuilder { + p.proc.Image = image + return p +} + +// WithCommand sets the command and arguments for the process. +func (p *ProcessBuilder) WithCommand(cmd ...string) *ProcessBuilder { + p.proc.Cmd = cmd + return p +} + +// WithStdin sets the standard input for the process. +func (p *ProcessBuilder) WithStdin(stdin string) *ProcessBuilder { + p.proc.Stdin = stdin + return p +} + +// WithMemoryLimit sets the memory limit in megabytes. +func (p *ProcessBuilder) WithMemoryLimit(mb int64) *ProcessBuilder { + p.proc.MemoryLimitMB = mb + return p +} + +// WithTimeLimit sets the time limit in milliseconds. +func (p *ProcessBuilder) WithTimeLimit(ms uint64) *ProcessBuilder { + p.proc.TimeLimitMs = ms + return p +} + +// WithProcLimit sets the maximum number of processes. +func (p *ProcessBuilder) WithProcLimit(limit int64) *ProcessBuilder { + p.proc.ProcLimit = limit + return p +} + +// WithFiles specifies which files to make available in this step. +func (p *ProcessBuilder) WithFiles(files ...string) *ProcessBuilder { + p.proc.Files = append(p.proc.Files, files...) + return p +} + +// WithPersist specifies which files to persist to the next step. +func (p *ProcessBuilder) WithPersist(files ...string) *ProcessBuilder { + p.proc.Persist = append(p.proc.Persist, files...) + return p +} + +// Build returns the constructed Process. +func (p *ProcessBuilder) Build() Process { + return p.proc +} + +// Common helper functions for building requests + +// SimpleExecRequest creates a simple single-step execution request. +// This is a convenience function for common use cases. +func SimpleExecRequest(image string, cmd []string, files map[string]string) *ExecRequest { + req := NewRequest() + + // Add all files + for name, content := range files { + req.AddFile(name, content) + } + + // Add single step with all files + fileNames := make([]string, 0, len(files)) + for name := range files { + fileNames = append(fileNames, name) + } + + req.AddStep(func(p *ProcessBuilder) { + p.WithImage(image). + WithCommand(cmd...). + WithFiles(fileNames...) + }) + + return req.Build() +} + +// CompileAndRunRequest creates a two-step request for compile-then-run scenarios. +// This is useful for compiled languages like C++, Java, etc. +func CompileAndRunRequest( + compileImage string, + compileCmd []string, + runImage string, + runCmd []string, + sourceFiles map[string]string, + compiledOutputs []string, + stdin string, +) *ExecRequest { + req := NewRequest() + + // Add source files + for name, content := range sourceFiles { + req.AddFile(name, content) + } + + // Get source file names + sourceFileNames := make([]string, 0, len(sourceFiles)) + for name := range sourceFiles { + sourceFileNames = append(sourceFileNames, name) + } + + // Step 1: Compile + req.AddStep(func(p *ProcessBuilder) { + p.WithImage(compileImage). + WithCommand(compileCmd...). + WithFiles(sourceFileNames...). + WithPersist(compiledOutputs...). + WithMemoryLimit(512). + WithTimeLimit(10000) + }) + + // Step 2: Run + req.AddStep(func(p *ProcessBuilder) { + p.WithImage(runImage). + WithCommand(runCmd...). + WithFiles(compiledOutputs...). + WithStdin(stdin). + WithMemoryLimit(256). + WithTimeLimit(5000) + }) + + return req.Build() +} diff --git a/client/client.go b/client/client.go new file mode 100644 index 0000000..0a47d7f --- /dev/null +++ b/client/client.go @@ -0,0 +1,307 @@ +// Package client provides a high-level client library for interacting with mycastletown +// sandboxed code execution service. It supports both HTTP REST and gRPC protocols. +package client + +import ( + "context" + "time" + + pb "github.com/joshjms/castletown/proto" +) + +// Client is the unified interface for interacting with mycastletown service. +// It supports both HTTP REST and gRPC protocols through different implementations. +type Client interface { + // Execute submits a job for execution and returns the results. + // If req.ID is empty, a unique ID will be generated by the server. + Execute(ctx context.Context, req *ExecRequest) (*ExecResponse, error) + + // Done notifies the server that the job is complete and can be cleaned up. + // This is optional but recommended to free up resources on the server. + Done(ctx context.Context, jobID string) error + + // Close closes the client and releases any resources. + Close() error +} + +// ExecRequest represents a code execution request. +// It contains files to be created in the sandbox and steps/processes to execute. +type ExecRequest struct { + // ID is a unique identifier for this job. If empty, the server will generate one. + ID string + + // Files are the files to create in the sandbox environment. + Files []File + + // Steps are the processes to execute sequentially. + // Each step can access files from previous steps if persisted. + Steps []Process +} + +// File represents a file to be created in the sandbox. +type File struct { + // Name is the filename (can include relative paths). + Name string + + // Content is the file content. + Content string +} + +// Process represents a single execution step in the sandbox. +type Process struct { + // Image is the container image name (must be available on server). + // Example: "gcc:15-bookworm" + Image string + + // Cmd is the command and arguments to execute. + // Example: []string{"g++", "main.cpp", "-o", "main"} + Cmd []string + + // Stdin is the standard input to provide to the process (optional). + Stdin string + + // MemoryLimitMB is the memory limit in megabytes (0 = unlimited). + MemoryLimitMB int64 + + // TimeLimitMs is the time limit in milliseconds (0 = unlimited). + TimeLimitMs uint64 + + // ProcLimit is the maximum number of processes (0 = unlimited). + ProcLimit int64 + + // Files specifies which files to make available in this step. + // References files by name from the Files array. + Files []string + + // Persist specifies which output files to persist to the next step. + // Only files listed here will be available to subsequent steps. + Persist []string +} + +// ExecResponse contains the execution results. +type ExecResponse struct { + // ID is the unique job identifier. + ID string + + // Reports contains one report per executed process/step. + Reports []Report +} + +// Report contains the execution results for a single process. +type Report struct { + // Status indicates the execution status. + Status Status + + // ExitCode is the process exit code. + ExitCode int32 + + // Signal is the signal that terminated the process (-1 if normal exit). + Signal int32 + + // Stdout is the standard output captured from the process. + Stdout string + + // Stderr is the standard error captured from the process. + Stderr string + + // CPUTime is the CPU time used in nanoseconds. + CPUTime uint64 + + // Memory is the peak memory usage in bytes. + Memory uint64 + + // WallTime is the wall clock time in milliseconds. + WallTime int64 + + // StartAt is the start timestamp in Unix nanoseconds. + StartAt int64 + + // FinishAt is the finish timestamp in Unix nanoseconds. + FinishAt int64 +} + +// Status represents the execution status of a process. +type Status int32 + +const ( + StatusUnspecified Status = 0 + StatusOK Status = 1 + StatusRuntimeError Status = 2 + StatusTimeLimitExceeded Status = 3 + StatusMemoryLimitExceeded Status = 4 + StatusOutputLimitExceeded Status = 5 + StatusTerminated Status = 6 + StatusUnknown Status = 7 + StatusSkipped Status = 8 +) + +// String returns the string representation of the status. +func (s Status) String() string { + switch s { + case StatusUnspecified: + return "UNSPECIFIED" + case StatusOK: + return "OK" + case StatusRuntimeError: + return "RUNTIME_ERROR" + case StatusTimeLimitExceeded: + return "TIME_LIMIT_EXCEEDED" + case StatusMemoryLimitExceeded: + return "MEMORY_LIMIT_EXCEEDED" + case StatusOutputLimitExceeded: + return "OUTPUT_LIMIT_EXCEEDED" + case StatusTerminated: + return "TERMINATED" + case StatusUnknown: + return "UNKNOWN" + case StatusSkipped: + return "SKIPPED" + default: + return "UNKNOWN" + } +} + +// ClientOptions contains configuration options for creating a client. +type ClientOptions struct { + // Address is the server address. For HTTP, include the scheme (http://localhost:8000). + // For gRPC, use host:port format (localhost:8001). + Address string + + // Timeout is the default timeout for requests (default: 30 seconds). + Timeout time.Duration + + // GRPCOptions contains additional gRPC-specific options (only used for gRPC client). + GRPCOptions *GRPCOptions +} + +// GRPCOptions contains gRPC-specific configuration options. +type GRPCOptions struct { + // Insecure, when true, disables transport security (TLS). + // Use this for testing or when connecting to insecure servers. + Insecure bool + + // MaxMessageSize sets the maximum message size in bytes for gRPC (default: 4MB). + MaxMessageSize int +} + +// NewHTTPClient creates a new HTTP REST client for mycastletown. +// +// Example: +// +// client, err := client.NewHTTPClient("http://localhost:8000", nil) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +func NewHTTPClient(address string, opts *ClientOptions) (Client, error) { + if opts == nil { + opts = &ClientOptions{ + Address: address, + Timeout: 30 * time.Second, + } + } + if opts.Address == "" { + opts.Address = address + } + if opts.Timeout == 0 { + opts.Timeout = 30 * time.Second + } + + return &httpClient{ + address: opts.Address, + timeout: opts.Timeout, + }, nil +} + +// NewGRPCClient creates a new gRPC client for mycastletown. +// +// Example: +// +// opts := &client.ClientOptions{ +// GRPCOptions: &client.GRPCOptions{ +// Insecure: true, +// }, +// } +// client, err := client.NewGRPCClient("localhost:8001", opts) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +func NewGRPCClient(address string, opts *ClientOptions) (Client, error) { + if opts == nil { + opts = &ClientOptions{ + Address: address, + Timeout: 30 * time.Second, + GRPCOptions: &GRPCOptions{ + Insecure: true, + MaxMessageSize: 4 * 1024 * 1024, // 4MB + }, + } + } + if opts.Address == "" { + opts.Address = address + } + if opts.Timeout == 0 { + opts.Timeout = 30 * time.Second + } + if opts.GRPCOptions == nil { + opts.GRPCOptions = &GRPCOptions{ + Insecure: true, + MaxMessageSize: 4 * 1024 * 1024, + } + } + if opts.GRPCOptions.MaxMessageSize == 0 { + opts.GRPCOptions.MaxMessageSize = 4 * 1024 * 1024 + } + + return newGRPCClient(address, opts) +} + +// Helper functions to convert between client types and protobuf types + +func toProtoFiles(files []File) []*pb.File { + result := make([]*pb.File, len(files)) + for i, f := range files { + result[i] = &pb.File{ + Name: f.Name, + Content: f.Content, + } + } + return result +} + +func toProtoProcesses(processes []Process) []*pb.Process { + result := make([]*pb.Process, len(processes)) + for i, p := range processes { + result[i] = &pb.Process{ + Image: p.Image, + Cmd: p.Cmd, + Stdin: p.Stdin, + MemoryLimitMb: p.MemoryLimitMB, + TimeLimitMs: p.TimeLimitMs, + ProcLimit: p.ProcLimit, + Files: p.Files, + Persist: p.Persist, + } + } + return result +} + +func fromProtoReports(reports []*pb.Report) []Report { + result := make([]Report, len(reports)) + for i, r := range reports { + result[i] = Report{ + Status: Status(r.Status), + ExitCode: r.ExitCode, + Signal: r.Signal, + Stdout: r.Stdout, + Stderr: r.Stderr, + CPUTime: r.CpuTime, + Memory: r.Memory, + WallTime: r.WallTime, + StartAt: r.StartAt, + FinishAt: r.FinishAt, + } + } + return result +} diff --git a/client/examples/advanced/main.go b/client/examples/advanced/main.go new file mode 100644 index 0000000..a4e4b35 --- /dev/null +++ b/client/examples/advanced/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/joshjms/castletown/client" +) + +func main() { + // Create HTTP client with custom timeout + opts := &client.ClientOptions{ + Timeout: 60 * time.Second, + } + c, err := client.NewHTTPClient("http://localhost:8000", opts) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + defer c.Close() + + // Python source code + pythonSource := `import sys + +def factorial(n): + if n <= 1: + return 1 + return n * factorial(n - 1) + +n = int(input()) +result = factorial(n) +print(f"Factorial of {n} is {result}") +` + + // Create a request with resource limits + req := client.NewRequest(). + WithID("advanced-example-job"). // Custom job ID + AddFile("factorial.py", pythonSource). + AddFile("input.txt", "10"). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("/bin/cat", "input.txt"). + WithFiles("input.txt"). + WithPersist("input.txt"). // Persist to next step + WithMemoryLimit(128). // 128 MB limit + WithTimeLimit(2000). // 2 second limit + WithProcLimit(10) // Max 10 processes + }). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("python3", "factorial.py"). + WithFiles("factorial.py", "input.txt"). + WithStdin("10"). // Alternative: use stdin directly + WithMemoryLimit(256). + WithTimeLimit(5000). + WithProcLimit(20) + }). + Build() + + // Execute with context + ctx := context.Background() + resp, err := c.Execute(ctx, req) + if err != nil { + log.Fatalf("Failed to execute: %v", err) + } + + // Print detailed results + fmt.Printf("Job ID: %s\n", resp.ID) + fmt.Printf("Total steps: %d\n", len(resp.Reports)) + + for i, report := range resp.Reports { + fmt.Printf("\n=== Step %d ===\n", i+1) + fmt.Printf("Status: %s\n", report.Status) + fmt.Printf("Exit Code: %d\n", report.ExitCode) + + if report.Signal != -1 { + fmt.Printf("Signal: %d\n", report.Signal) + } + + // Check for errors + switch report.Status { + case client.StatusOK: + fmt.Println("✓ Execution successful") + case client.StatusRuntimeError: + fmt.Println("✗ Runtime error") + case client.StatusTimeLimitExceeded: + fmt.Println("✗ Time limit exceeded") + case client.StatusMemoryLimitExceeded: + fmt.Println("✗ Memory limit exceeded") + default: + fmt.Printf("✗ Status: %s\n", report.Status) + } + + // Output + if report.Stdout != "" { + fmt.Printf("\nStdout:\n%s\n", report.Stdout) + } + if report.Stderr != "" { + fmt.Printf("\nStderr:\n%s\n", report.Stderr) + } + + // Resource usage + fmt.Printf("\nResource Usage:\n") + fmt.Printf(" CPU Time: %.2f ms\n", float64(report.CPUTime)/1_000_000) + fmt.Printf(" Memory: %.2f MB\n", float64(report.Memory)/1_048_576) + fmt.Printf(" Wall Time: %d ms\n", report.WallTime) + + // Timing + duration := time.Duration(report.FinishAt - report.StartAt) + fmt.Printf(" Total Duration: %v\n", duration) + } + + // Mark job as done + fmt.Println("\nCleaning up...") + if err := c.Done(ctx, resp.ID); err != nil { + log.Printf("Warning: Failed to mark job as done: %v", err) + } else { + fmt.Println("Job marked as done successfully") + } +} diff --git a/client/examples/basic_grpc/main.go b/client/examples/basic_grpc/main.go new file mode 100644 index 0000000..d7acaf3 --- /dev/null +++ b/client/examples/basic_grpc/main.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/joshjms/castletown/client" +) + +func main() { + // Create gRPC client + opts := &client.ClientOptions{ + GRPCOptions: &client.GRPCOptions{ + Insecure: true, // Use insecure connection (for testing) + }, + } + c, err := client.NewGRPCClient("localhost:8001", opts) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + defer c.Close() + + // Create a simple request using the builder + req := client.NewRequest(). + AddFile("hello.txt", "Hello from gRPC!"). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("/bin/cat", "hello.txt"). + WithFiles("hello.txt") + }). + Build() + + // Execute the request + ctx := context.Background() + resp, err := c.Execute(ctx, req) + if err != nil { + log.Fatalf("Failed to execute: %v", err) + } + + // Print results + fmt.Printf("Job ID: %s\n", resp.ID) + for i, report := range resp.Reports { + fmt.Printf("\nStep %d:\n", i+1) + fmt.Printf(" Status: %s\n", report.Status) + fmt.Printf(" Exit Code: %d\n", report.ExitCode) + fmt.Printf(" Stdout: %s\n", report.Stdout) + fmt.Printf(" Stderr: %s\n", report.Stderr) + fmt.Printf(" CPU Time: %d ns\n", report.CPUTime) + fmt.Printf(" Memory: %d bytes\n", report.Memory) + } + + // Mark job as done (cleanup) + if err := c.Done(ctx, resp.ID); err != nil { + log.Printf("Warning: Failed to mark job as done: %v", err) + } +} diff --git a/client/examples/basic_http/main.go b/client/examples/basic_http/main.go new file mode 100644 index 0000000..f50ed5a --- /dev/null +++ b/client/examples/basic_http/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/joshjms/castletown/client" +) + +func main() { + // Create HTTP client + c, err := client.NewHTTPClient("http://localhost:8000", nil) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + defer c.Close() + + // Create a simple request using the builder + req := client.NewRequest(). + AddFile("hello.txt", "Hello, World!"). + AddStep(func(p *client.ProcessBuilder) { + p.WithImage("gcc:15-bookworm"). + WithCommand("/bin/cat", "hello.txt"). + WithFiles("hello.txt") + }). + Build() + + // Execute the request + ctx := context.Background() + resp, err := c.Execute(ctx, req) + if err != nil { + log.Fatalf("Failed to execute: %v", err) + } + + // Print results + fmt.Printf("Job ID: %s\n", resp.ID) + for i, report := range resp.Reports { + fmt.Printf("\nStep %d:\n", i+1) + fmt.Printf(" Status: %s\n", report.Status) + fmt.Printf(" Exit Code: %d\n", report.ExitCode) + fmt.Printf(" Stdout: %s\n", report.Stdout) + fmt.Printf(" Stderr: %s\n", report.Stderr) + fmt.Printf(" CPU Time: %d ns\n", report.CPUTime) + fmt.Printf(" Memory: %d bytes\n", report.Memory) + } + + // Mark job as done (cleanup) + if err := c.Done(ctx, resp.ID); err != nil { + log.Printf("Warning: Failed to mark job as done: %v", err) + } +} diff --git a/client/examples/compile_and_run/main.go b/client/examples/compile_and_run/main.go new file mode 100644 index 0000000..f1fdcd1 --- /dev/null +++ b/client/examples/compile_and_run/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "fmt" + "log" + + "github.com/joshjms/castletown/client" +) + +func main() { + // Create HTTP client + c, err := client.NewHTTPClient("http://localhost:8000", nil) + if err != nil { + log.Fatalf("Failed to create client: %v", err) + } + defer c.Close() + + // C++ source code + cppSource := `#include +#include + +int main() { + std::string name; + std::getline(std::cin, name); + std::cout << "Hello, " << name << "!" << std::endl; + return 0; +}` + + // Use the compile-and-run helper + req := client.CompileAndRunRequest( + "gcc:15-bookworm", // Compile image + []string{"g++", "main.cpp", "-o", "main"}, // Compile command + "gcc:15-bookworm", // Run image + []string{"./main"}, // Run command + map[string]string{ // Source files + "main.cpp": cppSource, + }, + []string{"main"}, // Compiled outputs to persist + "World", // Stdin for the program + ) + + // Execute the request + ctx := context.Background() + resp, err := c.Execute(ctx, req) + if err != nil { + log.Fatalf("Failed to execute: %v", err) + } + + // Print results + fmt.Printf("Job ID: %s\n", resp.ID) + fmt.Println("\n--- Compilation Step ---") + compileReport := resp.Reports[0] + fmt.Printf("Status: %s\n", compileReport.Status) + fmt.Printf("Exit Code: %d\n", compileReport.ExitCode) + if compileReport.Stdout != "" { + fmt.Printf("Stdout: %s\n", compileReport.Stdout) + } + if compileReport.Stderr != "" { + fmt.Printf("Stderr: %s\n", compileReport.Stderr) + } + fmt.Printf("CPU Time: %d ns\n", compileReport.CPUTime) + fmt.Printf("Memory: %d bytes\n", compileReport.Memory) + + fmt.Println("\n--- Execution Step ---") + runReport := resp.Reports[1] + fmt.Printf("Status: %s\n", runReport.Status) + fmt.Printf("Exit Code: %d\n", runReport.ExitCode) + fmt.Printf("Stdout: %s\n", runReport.Stdout) + if runReport.Stderr != "" { + fmt.Printf("Stderr: %s\n", runReport.Stderr) + } + fmt.Printf("CPU Time: %d ns\n", runReport.CPUTime) + fmt.Printf("Memory: %d bytes\n", runReport.Memory) + + // Mark job as done (cleanup) + if err := c.Done(ctx, resp.ID); err != nil { + log.Printf("Warning: Failed to mark job as done: %v", err) + } +} diff --git a/client/grpc.go b/client/grpc.go new file mode 100644 index 0000000..dc7887f --- /dev/null +++ b/client/grpc.go @@ -0,0 +1,111 @@ +package client + +import ( + "context" + "fmt" + "time" + + pb "github.com/joshjms/castletown/proto" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// grpcClient implements the Client interface using gRPC. +type grpcClient struct { + conn *grpc.ClientConn + execClient pb.ExecServiceClient + doneClient pb.DoneServiceClient + timeout time.Duration +} + +// newGRPCClient creates a new gRPC client. +func newGRPCClient(address string, opts *ClientOptions) (*grpcClient, error) { + // Build gRPC dial options + dialOpts := []grpc.DialOption{} + + if opts.GRPCOptions.Insecure { + dialOpts = append(dialOpts, grpc.WithTransportCredentials(insecure.NewCredentials())) + } + + if opts.GRPCOptions.MaxMessageSize > 0 { + dialOpts = append(dialOpts, + grpc.WithDefaultCallOptions( + grpc.MaxCallRecvMsgSize(opts.GRPCOptions.MaxMessageSize), + grpc.MaxCallSendMsgSize(opts.GRPCOptions.MaxMessageSize), + ), + ) + } + + // Connect to server + conn, err := grpc.NewClient(address, dialOpts...) + if err != nil { + return nil, fmt.Errorf("failed to connect to gRPC server: %w", err) + } + + return &grpcClient{ + conn: conn, + execClient: pb.NewExecServiceClient(conn), + doneClient: pb.NewDoneServiceClient(conn), + timeout: opts.Timeout, + }, nil +} + +// Execute submits a job for execution via gRPC. +func (c *grpcClient) Execute(ctx context.Context, req *ExecRequest) (*ExecResponse, error) { + // Set timeout if not already set in context + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.timeout) + defer cancel() + } + + // Convert to protobuf format + pbReq := &pb.ExecRequest{ + Id: req.ID, + Files: toProtoFiles(req.Files), + Procs: toProtoProcesses(req.Steps), + } + + // Call gRPC method + pbResp, err := c.execClient.Execute(ctx, pbReq) + if err != nil { + return nil, fmt.Errorf("gRPC Execute failed: %w", err) + } + + // Convert response + return &ExecResponse{ + ID: pbResp.Id, + Reports: fromProtoReports(pbResp.Reports), + }, nil +} + +// Done notifies the server that a job is complete via gRPC. +func (c *grpcClient) Done(ctx context.Context, jobID string) error { + // Set timeout if not already set in context + if _, hasDeadline := ctx.Deadline(); !hasDeadline { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, c.timeout) + defer cancel() + } + + // Create request + pbReq := &pb.DoneRequest{ + Id: jobID, + } + + // Call gRPC method + _, err := c.doneClient.Done(ctx, pbReq) + if err != nil { + return fmt.Errorf("gRPC Done failed: %w", err) + } + + return nil +} + +// Close closes the gRPC connection. +func (c *grpcClient) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} diff --git a/client/http.go b/client/http.go new file mode 100644 index 0000000..dd14168 --- /dev/null +++ b/client/http.go @@ -0,0 +1,229 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// httpClient implements the Client interface using HTTP REST API. +type httpClient struct { + address string + timeout time.Duration + client *http.Client +} + +// httpExecRequest is the HTTP JSON request format for /exec endpoint. +type httpExecRequest struct { + ID string `json:"id,omitempty"` + Files []httpFile `json:"files"` + Steps []httpProcess `json:"steps"` +} + +// httpFile is the HTTP JSON format for a file. +type httpFile struct { + Name string `json:"name"` + Content string `json:"content"` +} + +// httpProcess is the HTTP JSON format for a process. +type httpProcess struct { + Image string `json:"image"` + Cmd []string `json:"cmd"` + Stdin string `json:"stdin,omitempty"` + MemoryLimitMB int64 `json:"memoryLimitMB,omitempty"` + TimeLimitMs uint64 `json:"timeLimitMs,omitempty"` + ProcLimit int64 `json:"procLimit,omitempty"` + Files []string `json:"files,omitempty"` + Persist []string `json:"persist,omitempty"` +} + +// httpExecResponse is the HTTP JSON response format for /exec endpoint. +type httpExecResponse struct { + ID string `json:"id"` + Reports []httpReport `json:"reports"` +} + +// httpReport is the HTTP JSON format for a report. +type httpReport struct { + Status string `json:"Status"` + ExitCode int32 `json:"ExitCode"` + Signal int32 `json:"Signal"` + Stdout string `json:"Stdout"` + Stderr string `json:"Stderr"` + CPUTime uint64 `json:"CPUTime"` + Memory uint64 `json:"Memory"` + WallTime int64 `json:"WallTime"` + StartAt int64 `json:"StartAt"` + FinishAt int64 `json:"FinishAt"` +} + +// httpDoneRequest is the HTTP JSON request format for /done endpoint. +type httpDoneRequest struct { + ID string `json:"id"` +} + +// Execute submits a job for execution via HTTP REST API. +func (c *httpClient) Execute(ctx context.Context, req *ExecRequest) (*ExecResponse, error) { + // Convert to HTTP format + httpReq := httpExecRequest{ + ID: req.ID, + Files: make([]httpFile, len(req.Files)), + Steps: make([]httpProcess, len(req.Steps)), + } + + for i, f := range req.Files { + httpReq.Files[i] = httpFile{ + Name: f.Name, + Content: f.Content, + } + } + + for i, p := range req.Steps { + httpReq.Steps[i] = httpProcess{ + Image: p.Image, + Cmd: p.Cmd, + Stdin: p.Stdin, + MemoryLimitMB: p.MemoryLimitMB, + TimeLimitMs: p.TimeLimitMs, + ProcLimit: p.ProcLimit, + Files: p.Files, + Persist: p.Persist, + } + } + + // Marshal to JSON + body, err := json.Marshal(httpReq) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + httpRequest, err := http.NewRequestWithContext(ctx, "POST", c.address+"/exec", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + httpRequest.Header.Set("Content-Type", "application/json") + + // Send request + if c.client == nil { + c.client = &http.Client{ + Timeout: c.timeout, + } + } + + resp, err := c.client.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("failed to send HTTP request: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + // Parse response + var httpResp httpExecResponse + if err := json.NewDecoder(resp.Body).Decode(&httpResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + // Convert to client format + response := &ExecResponse{ + ID: httpResp.ID, + Reports: make([]Report, len(httpResp.Reports)), + } + + for i, r := range httpResp.Reports { + response.Reports[i] = Report{ + Status: parseStatus(r.Status), + ExitCode: r.ExitCode, + Signal: r.Signal, + Stdout: r.Stdout, + Stderr: r.Stderr, + CPUTime: r.CPUTime, + Memory: r.Memory, + WallTime: r.WallTime, + StartAt: r.StartAt, + FinishAt: r.FinishAt, + } + } + + return response, nil +} + +// Done notifies the server that a job is complete via HTTP REST API. +func (c *httpClient) Done(ctx context.Context, jobID string) error { + // Create request + httpReq := httpDoneRequest{ + ID: jobID, + } + + body, err := json.Marshal(httpReq) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Create HTTP request + httpRequest, err := http.NewRequestWithContext(ctx, "POST", c.address+"/done", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create HTTP request: %w", err) + } + httpRequest.Header.Set("Content-Type", "application/json") + + // Send request + if c.client == nil { + c.client = &http.Client{ + Timeout: c.timeout, + } + } + + resp, err := c.client.Do(httpRequest) + if err != nil { + return fmt.Errorf("failed to send HTTP request: %w", err) + } + defer resp.Body.Close() + + // Check status code + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + return nil +} + +// Close closes the HTTP client (no-op for HTTP). +func (c *httpClient) Close() error { + return nil +} + +// parseStatus converts a string status to Status enum. +func parseStatus(s string) Status { + switch s { + case "OK": + return StatusOK + case "RUNTIME_ERROR": + return StatusRuntimeError + case "TIME_LIMIT_EXCEEDED": + return StatusTimeLimitExceeded + case "MEMORY_LIMIT_EXCEEDED": + return StatusMemoryLimitExceeded + case "OUTPUT_LIMIT_EXCEEDED": + return StatusOutputLimitExceeded + case "TERMINATED": + return StatusTerminated + case "SKIPPED": + return StatusSkipped + case "UNKNOWN": + return StatusUnknown + default: + return StatusUnspecified + } +}