Skip to content

Commit cb0a2bc

Browse files
authored
Adding a direct backend option of the self hosted runner (#33)
1 parent 4bef8cf commit cb0a2bc

7 files changed

Lines changed: 403 additions & 29 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ require (
3333
github.com/go-playground/validator/v10 v10.30.1 // indirect
3434
github.com/golang/protobuf v1.5.0 // indirect
3535
github.com/gorilla/mux v1.8.1 // indirect
36+
github.com/joho/godotenv v1.5.1 // indirect
3637
github.com/leodido/go-urn v1.4.0 // indirect
3738
github.com/mattn/go-colorable v0.1.13 // indirect
3839
github.com/mattn/go-isatty v0.0.19 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLW
105105
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
106106
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
107107
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
108+
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
109+
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
108110
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
109111
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
110112
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type FileConfig struct {
2323
// At most one backend field may be non-nil; configuring multiple backends simultaneously is an error.
2424
type BackendConfig struct {
2525
Docker *DockerConfig `yaml:"docker"`
26+
Direct *DirectConfig `yaml:"direct"`
2627
}
2728

2829
// DockerConfig holds Docker-backend-specific configuration.
@@ -31,6 +32,15 @@ type DockerConfig struct {
3132
Environment []EnvEntry `yaml:"environment" validate:"dive"`
3233
}
3334

35+
// DirectConfig holds direct-backend-specific configuration.
36+
type DirectConfig struct {
37+
WorkspaceRoot string `yaml:"workspace_root"`
38+
OzPath string `yaml:"oz_path"`
39+
SetupCommand string `yaml:"setup_command"`
40+
TeardownCommand string `yaml:"teardown_command"`
41+
Environment []EnvEntry `yaml:"environment" validate:"dive"`
42+
}
43+
3444
// EnvEntry represents a single environment variable in the config file.
3545
// If Value is nil, the variable is inherited from the host process environment.
3646
type EnvEntry struct {

internal/config/config_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,59 @@ func TestResolveEnvMissingHostVar(t *testing.T) {
197197
}
198198
}
199199

200+
func TestLoadValidDirectConfig(t *testing.T) {
201+
path := writeTestConfig(t, `
202+
worker_id: "direct-worker"
203+
backend:
204+
direct:
205+
workspace_root: "/tmp/oz-workspaces"
206+
setup_command: "/opt/setup.sh"
207+
teardown_command: "/opt/teardown.sh"
208+
environment:
209+
- name: MY_VAR
210+
value: "hello"
211+
`)
212+
213+
cfg, err := Load(path)
214+
if err != nil {
215+
t.Fatalf("unexpected error: %v", err)
216+
}
217+
218+
if cfg.Backend.Direct == nil {
219+
t.Fatal("expected direct backend to be set")
220+
}
221+
if cfg.Backend.Docker != nil {
222+
t.Error("docker backend should be nil")
223+
}
224+
if cfg.Backend.Direct.WorkspaceRoot != "/tmp/oz-workspaces" {
225+
t.Errorf("workspace_root = %q, want %q", cfg.Backend.Direct.WorkspaceRoot, "/tmp/oz-workspaces")
226+
}
227+
if cfg.Backend.Direct.SetupCommand != "/opt/setup.sh" {
228+
t.Errorf("setup_command = %q, want %q", cfg.Backend.Direct.SetupCommand, "/opt/setup.sh")
229+
}
230+
if cfg.Backend.Direct.TeardownCommand != "/opt/teardown.sh" {
231+
t.Errorf("teardown_command = %q, want %q", cfg.Backend.Direct.TeardownCommand, "/opt/teardown.sh")
232+
}
233+
if len(cfg.Backend.Direct.Environment) != 1 {
234+
t.Errorf("environment count = %d, want 1", len(cfg.Backend.Direct.Environment))
235+
}
236+
}
237+
238+
func TestLoadBothBackendsError(t *testing.T) {
239+
path := writeTestConfig(t, `
240+
backend:
241+
docker:
242+
volumes: []
243+
direct:
244+
workspace_root: "/tmp"
245+
`)
246+
247+
_, err := Load(path)
248+
if err == nil {
249+
t.Fatal("expected error when both backends are set")
250+
}
251+
}
252+
200253
func TestLoadFileNotFound(t *testing.T) {
201254
_, err := Load("/nonexistent/path/config.yaml")
202255
if err == nil {

internal/worker/direct.go

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
package worker
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"strings"
10+
11+
"github.com/joho/godotenv"
12+
"github.com/warpdotdev/oz-agent-worker/internal/log"
13+
)
14+
15+
const defaultWorkspaceRoot = "/var/lib/oz/workspaces"
16+
17+
// defaultInheritedEnvVars are the host environment variables passed through to
18+
// tasks and scripts by default. Sensitive worker credentials are intentionally
19+
// excluded; additional variables can be opted in via the backend config.
20+
var defaultInheritedEnvVars = []string{"HOME", "TMPDIR", "PATH"}
21+
22+
// hostBaseEnv builds a minimal env slice from the host, containing only the
23+
// keys listed in defaultInheritedEnvVars.
24+
func hostBaseEnv() []string {
25+
var base []string
26+
for _, key := range defaultInheritedEnvVars {
27+
if val, ok := os.LookupEnv(key); ok {
28+
base = append(base, fmt.Sprintf("%s=%s", key, val))
29+
}
30+
}
31+
return base
32+
}
33+
34+
// DirectBackendConfig holds configuration specific to the direct (non-containerized) backend.
35+
type DirectBackendConfig struct {
36+
WorkspaceRoot string
37+
OzPath string // Path to the oz CLI binary. If empty, looks up "oz" in PATH.
38+
SetupCommand string
39+
TeardownCommand string
40+
NoCleanup bool
41+
Env map[string]string
42+
}
43+
44+
// DirectBackend executes tasks directly on the host without Docker.
45+
type DirectBackend struct {
46+
config DirectBackendConfig
47+
ozPath string // resolved path to the oz CLI
48+
}
49+
50+
// NewDirectBackend creates a new direct backend, verifying the oz CLI is available.
51+
func NewDirectBackend(ctx context.Context, config DirectBackendConfig) (*DirectBackend, error) {
52+
ozPath := config.OzPath
53+
if ozPath == "" {
54+
var err error
55+
ozPath, err = exec.LookPath("oz")
56+
if err != nil {
57+
return nil, fmt.Errorf("oz CLI not found in PATH: %w", err)
58+
}
59+
}
60+
log.Infof(ctx, "Using oz CLI at: %s", ozPath)
61+
62+
if config.WorkspaceRoot == "" {
63+
config.WorkspaceRoot = defaultWorkspaceRoot
64+
}
65+
66+
// Ensure workspace root exists.
67+
if err := os.MkdirAll(config.WorkspaceRoot, 0755); err != nil {
68+
return nil, fmt.Errorf("failed to create workspace root %s: %w", config.WorkspaceRoot, err)
69+
}
70+
71+
return &DirectBackend{
72+
config: config,
73+
ozPath: ozPath,
74+
}, nil
75+
}
76+
77+
// ExecuteTask runs the agent directly on the host.
78+
func (b *DirectBackend) ExecuteTask(ctx context.Context, params *TaskParams) error {
79+
taskID := params.TaskID
80+
81+
// 1. Create per-task workspace directory.
82+
workspaceDir := filepath.Join(b.config.WorkspaceRoot, taskID)
83+
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
84+
return fmt.Errorf("failed to create workspace directory: %w", err)
85+
}
86+
log.Infof(ctx, "Created workspace: %s", workspaceDir)
87+
88+
defer func() {
89+
if b.config.NoCleanup {
90+
log.Infof(ctx, "Skipping cleanup for workspace: %s", workspaceDir)
91+
return
92+
}
93+
b.cleanup(ctx, taskID, workspaceDir)
94+
}()
95+
96+
// 2. Create temp environment file for setup script to write to.
97+
envFile, err := os.CreateTemp(workspaceDir, "oz-env-*")
98+
if err != nil {
99+
return fmt.Errorf("failed to create environment file: %w", err)
100+
}
101+
envFilePath := envFile.Name()
102+
if err := envFile.Close(); err != nil {
103+
return fmt.Errorf("failed to close environment file: %w", err)
104+
}
105+
106+
// 3. Build environment variables: common + config-level.
107+
envVars := make([]string, len(params.EnvVars))
108+
copy(envVars, params.EnvVars)
109+
for key, value := range b.config.Env {
110+
envVars = append(envVars, fmt.Sprintf("%s=%s", key, value))
111+
}
112+
113+
// 4. Run setup command if configured.
114+
if b.config.SetupCommand != "" {
115+
setupEnv := append(envVars,
116+
fmt.Sprintf("OZ_WORKSPACE_ROOT=%s", workspaceDir),
117+
"OZ_WORKER_BACKEND=direct",
118+
fmt.Sprintf("OZ_RUN_ID=%s", taskID),
119+
fmt.Sprintf("OZ_ENVIRONMENT_FILE=%s", envFilePath),
120+
)
121+
122+
log.Infof(ctx, "Running setup command: %s", b.config.SetupCommand)
123+
if err := b.runCommand(ctx, b.config.SetupCommand, workspaceDir, setupEnv); err != nil {
124+
return fmt.Errorf("setup command failed: %w", err)
125+
}
126+
}
127+
128+
// 5. Parse environment file for KEY=VALUE pairs written by setup script.
129+
// Use merge semantics so setup script vars can override YAML config vars.
130+
setupScriptEnv, err := parseEnvFile(envFilePath)
131+
if err != nil {
132+
log.Warnf(ctx, "Failed to parse environment file: %v", err)
133+
}
134+
var setupScriptVars []string
135+
for key, value := range setupScriptEnv {
136+
setupScriptVars = append(setupScriptVars, fmt.Sprintf("%s=%s", key, value))
137+
}
138+
envVars = mergeEnvVars(envVars, setupScriptVars)
139+
140+
// 6. Invoke oz CLI with base args.
141+
// Start from a minimal host base (HOME, TMPDIR, PATH) and overlay task env vars,
142+
// so sensitive worker credentials are never exposed to tasks.
143+
cmd := exec.CommandContext(ctx, b.ozPath, params.BaseArgs...)
144+
cmd.Dir = workspaceDir
145+
cmd.Env = mergeEnvVars(hostBaseEnv(), envVars)
146+
cmd.Stdout = os.Stdout
147+
cmd.Stderr = os.Stderr
148+
149+
log.Infof(ctx, "Running oz agent in workspace %s", workspaceDir)
150+
log.Debugf(ctx, "Command: %s %s", b.ozPath, strings.Join(params.BaseArgs, " "))
151+
152+
if err := cmd.Run(); err != nil {
153+
return fmt.Errorf("oz agent exited with error: %w", err)
154+
}
155+
156+
log.Infof(ctx, "Task %s execution completed successfully", taskID)
157+
return nil
158+
}
159+
160+
// Shutdown cleans up any workspace directories left behind under the workspace root.
161+
func (b *DirectBackend) Shutdown(ctx context.Context) {
162+
entries, err := os.ReadDir(b.config.WorkspaceRoot)
163+
if err != nil {
164+
if !os.IsNotExist(err) {
165+
log.Warnf(ctx, "Failed to read workspace root %s during shutdown: %v", b.config.WorkspaceRoot, err)
166+
}
167+
return
168+
}
169+
for _, entry := range entries {
170+
path := filepath.Join(b.config.WorkspaceRoot, entry.Name())
171+
if err := os.RemoveAll(path); err != nil {
172+
log.Warnf(ctx, "Failed to remove workspace %s during shutdown: %v", path, err)
173+
} else {
174+
log.Infof(ctx, "Removed lingering workspace on shutdown: %s", path)
175+
}
176+
}
177+
}
178+
179+
// cleanup runs the teardown command (if configured) and removes the workspace directory.
180+
func (b *DirectBackend) cleanup(ctx context.Context, taskID, workspaceDir string) {
181+
if b.config.TeardownCommand != "" {
182+
teardownEnv := []string{
183+
fmt.Sprintf("OZ_WORKSPACE_ROOT=%s", workspaceDir),
184+
"OZ_WORKER_BACKEND=direct",
185+
fmt.Sprintf("OZ_RUN_ID=%s", taskID),
186+
}
187+
188+
log.Infof(ctx, "Running teardown command: %s", b.config.TeardownCommand)
189+
if err := b.runCommand(ctx, b.config.TeardownCommand, workspaceDir, teardownEnv); err != nil {
190+
log.Warnf(ctx, "Teardown command failed: %v", err)
191+
}
192+
}
193+
194+
log.Infof(ctx, "Removing workspace: %s", workspaceDir)
195+
if err := os.RemoveAll(workspaceDir); err != nil {
196+
log.Warnf(ctx, "Failed to remove workspace %s: %v", workspaceDir, err)
197+
}
198+
}
199+
200+
// runCommand executes a shell command with the given working directory and environment.
201+
// The command inherits only standard host vars (HOME, TMPDIR, PATH) plus env.
202+
func (b *DirectBackend) runCommand(ctx context.Context, command, dir string, env []string) error {
203+
cmd := exec.CommandContext(ctx, "/bin/sh", "-c", command)
204+
cmd.Dir = dir
205+
cmd.Env = mergeEnvVars(hostBaseEnv(), env)
206+
cmd.Stdout = os.Stdout
207+
cmd.Stderr = os.Stderr
208+
return cmd.Run()
209+
}
210+
211+
// mergeEnvVars merges base and override env var slices (KEY=VALUE format).
212+
// Override entries take precedence over base entries with the same key.
213+
func mergeEnvVars(base, override []string) []string {
214+
envMap := make(map[string]string, len(base)+len(override))
215+
var keys []string
216+
217+
for _, entry := range base {
218+
key, _, _ := strings.Cut(entry, "=")
219+
if _, exists := envMap[key]; !exists {
220+
keys = append(keys, key)
221+
}
222+
envMap[key] = entry
223+
}
224+
225+
for _, entry := range override {
226+
key, _, _ := strings.Cut(entry, "=")
227+
if _, exists := envMap[key]; !exists {
228+
keys = append(keys, key)
229+
}
230+
envMap[key] = entry
231+
}
232+
233+
result := make([]string, 0, len(keys))
234+
for _, key := range keys {
235+
result = append(result, envMap[key])
236+
}
237+
return result
238+
}
239+
240+
// parseEnvFile reads a dotenv-format file and returns a map of KEY=VALUE pairs.
241+
// It supports quoted values, comments, and other standard dotenv syntax via godotenv.
242+
func parseEnvFile(path string) (map[string]string, error) {
243+
return godotenv.Read(path)
244+
}

internal/worker/worker.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ type Config struct {
3030
WebSocketURL string
3131
ServerRootURL string
3232
LogLevel string
33-
NoCleanup bool
34-
Volumes []string
35-
Env map[string]string
33+
BackendType string // "docker" or "direct"
34+
35+
// Backend-specific configs. Only the one matching BackendType should be set.
36+
Docker *DockerBackendConfig
37+
Direct *DirectBackendConfig
3638
}
3739

3840
type Worker struct {
@@ -52,11 +54,26 @@ type Worker struct {
5254
func New(ctx context.Context, config Config) (*Worker, error) {
5355
workerCtx, cancel := context.WithCancel(ctx)
5456

55-
backend, err := NewDockerBackend(ctx, DockerBackendConfig{
56-
NoCleanup: config.NoCleanup,
57-
Volumes: config.Volumes,
58-
Env: config.Env,
59-
})
57+
var backend Backend
58+
var err error
59+
60+
switch config.BackendType {
61+
case "direct":
62+
if config.Direct == nil {
63+
cancel()
64+
return nil, fmt.Errorf("direct backend selected but no direct config provided")
65+
}
66+
backend, err = NewDirectBackend(ctx, *config.Direct)
67+
case "docker", "":
68+
if config.Docker == nil {
69+
config.Docker = &DockerBackendConfig{}
70+
}
71+
backend, err = NewDockerBackend(ctx, *config.Docker)
72+
default:
73+
cancel()
74+
return nil, fmt.Errorf("unknown backend type: %q", config.BackendType)
75+
}
76+
6077
if err != nil {
6178
cancel()
6279
return nil, err

0 commit comments

Comments
 (0)