@@ -8,11 +8,13 @@ import ( |
| 8 | "errors" | 8 | "errors" |
| 9 | "fmt" | 9 | "fmt" |
| 10 | "io" | 10 | "io" |
| | 11 | + "log/slog" |
| 11 | "os" | 12 | "os" |
| 12 | "os/exec" | 13 | "os/exec" |
| 13 | "path" | 14 | "path" |
| 14 | "regexp" | 15 | "regexp" |
| 15 | "sort" | 16 | "sort" |
| | 17 | + "strconv" |
| 16 | "strings" | 18 | "strings" |
| 17 | "sync" | 19 | "sync" |
| 18 | "time" | 20 | "time" |
@@ -27,6 +29,19 @@ var ( |
| 27 | ErrUnsupported = errors.New("runner engine: unsupported operation") | 29 | ErrUnsupported = errors.New("runner engine: unsupported operation") |
| 28 | ) | 30 | ) |
| 29 | | 31 | |
| | 32 | +const ( |
| | 33 | + defaultSeccompProfile = "/etc/shithubd-runner/seccomp.json" |
| | 34 | + defaultContainerUser = "65534:65534" |
| | 35 | + defaultPidsLimit = 512 |
| | 36 | + defaultNofileLimit = "4096:4096" |
| | 37 | + defaultNprocLimit = "512:512" |
| | 38 | + |
| | 39 | + // rootPermissionKey is an intentionally shithub-specific escape hatch. |
| | 40 | + // It requires an explicit per-job permissions entry rather than treating |
| | 41 | + // broad write-all permissions as permission to run the container as root. |
| | 42 | + rootPermissionKey = "shithub-runner-root" |
| | 43 | +) |
| | 44 | + |
| 30 | type CommandRunner interface { | 45 | type CommandRunner interface { |
| 31 | Run(ctx context.Context, name string, args []string, stdout, stderr io.Writer) error | 46 | Run(ctx context.Context, name string, args []string, stdout, stderr io.Writer) error |
| 32 | } | 47 | } |
@@ -46,6 +61,9 @@ type DockerConfig struct { |
| 46 | Network string | 61 | Network string |
| 47 | Memory string | 62 | Memory string |
| 48 | CPUs string | 63 | CPUs string |
| | 64 | + SeccompProfile string |
| | 65 | + User string |
| | 66 | + PidsLimit int |
| 49 | LogChunkBytes int | 67 | LogChunkBytes int |
| 50 | LogFlushInterval time.Duration | 68 | LogFlushInterval time.Duration |
| 51 | StepLogLimit int64 | 69 | StepLogLimit int64 |
@@ -53,6 +71,7 @@ type DockerConfig struct { |
| 53 | Stderr io.Writer | 71 | Stderr io.Writer |
| 54 | Runner CommandRunner | 72 | Runner CommandRunner |
| 55 | MaskValues []string | 73 | MaskValues []string |
| | 74 | + Logger *slog.Logger |
| 56 | } | 75 | } |
| 57 | | 76 | |
| 58 | type Docker struct { | 77 | type Docker struct { |
@@ -84,6 +103,18 @@ func NewDocker(cfg DockerConfig) *Docker { |
| 84 | if cfg.Runner == nil { | 103 | if cfg.Runner == nil { |
| 85 | cfg.Runner = ExecRunner{} | 104 | cfg.Runner = ExecRunner{} |
| 86 | } | 105 | } |
| | 106 | + if cfg.SeccompProfile == "" { |
| | 107 | + cfg.SeccompProfile = defaultSeccompProfile |
| | 108 | + } |
| | 109 | + if cfg.User == "" { |
| | 110 | + cfg.User = defaultContainerUser |
| | 111 | + } |
| | 112 | + if cfg.PidsLimit <= 0 { |
| | 113 | + cfg.PidsLimit = defaultPidsLimit |
| | 114 | + } |
| | 115 | + if cfg.Logger == nil { |
| | 116 | + cfg.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) |
| | 117 | + } |
| 87 | return &Docker{cfg: cfg, streams: make(map[int64]chan LogChunk), eventSubs: make(map[int64]chan Event)} | 118 | return &Docker{cfg: cfg, streams: make(map[int64]chan LogChunk), eventSubs: make(map[int64]chan Event)} |
| 88 | } | 119 | } |
| 89 | | 120 | |
@@ -151,36 +182,50 @@ func (d *Docker) executeStep(ctx context.Context, job Job, step Step) error { |
| 151 | if strings.TrimSpace(step.Run) == "" { | 182 | if strings.TrimSpace(step.Run) == "" { |
| 152 | return nil | 183 | return nil |
| 153 | } | 184 | } |
| 154 | - args, err := d.dockerArgs(job, step) | 185 | + invocation, err := d.dockerInvocation(job, step) |
| 155 | if err != nil { | 186 | if err != nil { |
| 156 | return err | 187 | return err |
| 157 | } | 188 | } |
| | 189 | + d.logStep(ctx, "runner step starting", job, step, invocation, "") |
| 158 | writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues) | 190 | writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues) |
| 159 | out := io.MultiWriter(d.cfg.Stdout, writer) | 191 | out := io.MultiWriter(d.cfg.Stdout, writer) |
| 160 | errOut := io.MultiWriter(d.cfg.Stderr, writer) | 192 | errOut := io.MultiWriter(d.cfg.Stderr, writer) |
| 161 | - if err := d.cfg.Runner.Run(ctx, d.cfg.Binary, args, out, errOut); err != nil { | 193 | + if err := d.cfg.Runner.Run(ctx, d.cfg.Binary, invocation.args, out, errOut); err != nil { |
| | 194 | + d.logStep(ctx, "runner step completed", job, step, invocation, conclusionForError(err)) |
| 162 | if closeErr := writer.Close(); closeErr != nil { | 195 | if closeErr := writer.Close(); closeErr != nil { |
| 163 | return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), errors.Join(err, closeErr)) | 196 | return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), errors.Join(err, closeErr)) |
| 164 | } | 197 | } |
| 165 | return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), err) | 198 | return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), err) |
| 166 | } | 199 | } |
| | 200 | + d.logStep(ctx, "runner step completed", job, step, invocation, ConclusionSuccess) |
| 167 | if err := writer.Close(); err != nil { | 201 | if err := writer.Close(); err != nil { |
| 168 | return fmt.Errorf("runner engine: flush step %q logs: %w", stepLabel(step), err) | 202 | return fmt.Errorf("runner engine: flush step %q logs: %w", stepLabel(step), err) |
| 169 | } | 203 | } |
| 170 | return nil | 204 | return nil |
| 171 | } | 205 | } |
| 172 | | 206 | |
| 173 | -func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) { | 207 | +type dockerInvocation struct { |
| | 208 | + args []string |
| | 209 | + image string |
| | 210 | + network string |
| | 211 | + memory string |
| | 212 | + cpus string |
| | 213 | + user string |
| | 214 | + seccompProfile string |
| | 215 | + pidsLimit int |
| | 216 | +} |
| | 217 | + |
| | 218 | +func (d *Docker) dockerInvocation(job Job, step Step) (dockerInvocation, error) { |
| 174 | workdir, err := containerWorkdir(step.WorkingDirectory) | 219 | workdir, err := containerWorkdir(step.WorkingDirectory) |
| 175 | if err != nil { | 220 | if err != nil { |
| 176 | - return nil, err | 221 | + return dockerInvocation{}, err |
| 177 | } | 222 | } |
| 178 | image := strings.TrimSpace(job.Image) | 223 | image := strings.TrimSpace(job.Image) |
| 179 | if image == "" { | 224 | if image == "" { |
| 180 | image = d.cfg.DefaultImage | 225 | image = d.cfg.DefaultImage |
| 181 | } | 226 | } |
| 182 | if image == "" { | 227 | if image == "" { |
| 183 | - return nil, errors.New("runner engine: image is required") | 228 | + return dockerInvocation{}, errors.New("runner engine: image is required") |
| 184 | } | 229 | } |
| 185 | rendered, err := runnerexec.RenderStep(runnerexec.StepInput{ | 230 | rendered, err := runnerexec.RenderStep(runnerexec.StepInput{ |
| 186 | Run: step.Run, | 231 | Run: step.Run, |
@@ -189,7 +234,11 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) { |
| 189 | Context: expressionContext(job), | 234 | Context: expressionContext(job), |
| 190 | }) | 235 | }) |
| 191 | if err != nil { | 236 | if err != nil { |
| 192 | - return nil, fmt.Errorf("runner engine: render step %q: %w", stepLabel(step), err) | 237 | + return dockerInvocation{}, fmt.Errorf("runner engine: render step %q: %w", stepLabel(step), err) |
| | 238 | + } |
| | 239 | + user := d.cfg.User |
| | 240 | + if permissionsRequestRoot(job.Permissions) { |
| | 241 | + user = "0:0" |
| 193 | } | 242 | } |
| 194 | args := []string{ | 243 | args := []string{ |
| 195 | "run", | 244 | "run", |
@@ -197,18 +246,58 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) { |
| 197 | "--network=" + d.cfg.Network, | 246 | "--network=" + d.cfg.Network, |
| 198 | "--memory=" + d.cfg.Memory, | 247 | "--memory=" + d.cfg.Memory, |
| 199 | "--cpus=" + d.cfg.CPUs, | 248 | "--cpus=" + d.cfg.CPUs, |
| | 249 | + "--pids-limit=" + strconv.Itoa(d.cfg.PidsLimit), |
| | 250 | + "--read-only", |
| | 251 | + "--tmpfs", "/tmp:rw,exec,nosuid,nodev,size=1g", |
| | 252 | + "--cap-drop=ALL", |
| | 253 | + "--cap-add=DAC_OVERRIDE", |
| | 254 | + "--cap-add=SETGID", |
| | 255 | + "--cap-add=SETUID", |
| | 256 | + "--security-opt=no-new-privileges", |
| | 257 | + "--security-opt=seccomp=" + d.cfg.SeccompProfile, |
| | 258 | + "--ulimit", "nofile=" + defaultNofileLimit, |
| | 259 | + "--ulimit", "nproc=" + defaultNprocLimit, |
| | 260 | + "--user", user, |
| 200 | "--workdir=" + workdir, | 261 | "--workdir=" + workdir, |
| 201 | - "-v", job.WorkspaceDir + ":/workspace", | 262 | + "--mount", "type=bind,src=" + job.WorkspaceDir + ",dst=/workspace,rw", |
| 202 | } | 263 | } |
| 203 | env, err := validateEnv(rendered.Env) | 264 | env, err := validateEnv(rendered.Env) |
| 204 | if err != nil { | 265 | if err != nil { |
| 205 | - return nil, err | 266 | + return dockerInvocation{}, err |
| 206 | } | 267 | } |
| 207 | for _, key := range sortedKeys(env) { | 268 | for _, key := range sortedKeys(env) { |
| 208 | args = append(args, "-e", key+"="+env[key]) | 269 | args = append(args, "-e", key+"="+env[key]) |
| 209 | } | 270 | } |
| 210 | args = append(args, image, "bash", "-c", rendered.Run) | 271 | args = append(args, image, "bash", "-c", rendered.Run) |
| 211 | - return args, nil | 272 | + return dockerInvocation{ |
| | 273 | + args: args, |
| | 274 | + image: image, |
| | 275 | + network: d.cfg.Network, |
| | 276 | + memory: d.cfg.Memory, |
| | 277 | + cpus: d.cfg.CPUs, |
| | 278 | + user: user, |
| | 279 | + seccompProfile: d.cfg.SeccompProfile, |
| | 280 | + pidsLimit: d.cfg.PidsLimit, |
| | 281 | + }, nil |
| | 282 | +} |
| | 283 | + |
| | 284 | +func (d *Docker) logStep(ctx context.Context, msg string, job Job, step Step, invocation dockerInvocation, conclusion string) { |
| | 285 | + attrs := []any{ |
| | 286 | + "run_id", job.RunID, |
| | 287 | + "job_id", job.ID, |
| | 288 | + "step_id", step.ID, |
| | 289 | + "image", invocation.image, |
| | 290 | + "network", invocation.network, |
| | 291 | + "cpu_limit", invocation.cpus, |
| | 292 | + "memory_limit", invocation.memory, |
| | 293 | + "pids_limit", invocation.pidsLimit, |
| | 294 | + "container_user", invocation.user, |
| | 295 | + "seccomp_profile", invocation.seccompProfile, |
| | 296 | + } |
| | 297 | + if conclusion != "" { |
| | 298 | + attrs = append(attrs, "conclusion", conclusion) |
| | 299 | + } |
| | 300 | + d.cfg.Logger.InfoContext(ctx, msg, attrs...) |
| 212 | } | 301 | } |
| 213 | | 302 | |
| 214 | func expressionContext(job Job) expr.Context { | 303 | func expressionContext(job Job) expr.Context { |
@@ -227,6 +316,23 @@ func expressionContext(job Job) expr.Context { |
| 227 | } | 316 | } |
| 228 | } | 317 | } |
| 229 | | 318 | |
| | 319 | +func permissionsRequestRoot(raw json.RawMessage) bool { |
| | 320 | + if len(raw) == 0 || !json.Valid(raw) { |
| | 321 | + return false |
| | 322 | + } |
| | 323 | + var shaped struct { |
| | 324 | + Per map[string]string `json:"per"` |
| | 325 | + } |
| | 326 | + if err := json.Unmarshal(raw, &shaped); err == nil && strings.EqualFold(shaped.Per[rootPermissionKey], "write") { |
| | 327 | + return true |
| | 328 | + } |
| | 329 | + var flat map[string]string |
| | 330 | + if err := json.Unmarshal(raw, &flat); err != nil { |
| | 331 | + return false |
| | 332 | + } |
| | 333 | + return strings.EqualFold(flat[rootPermissionKey], "write") |
| | 334 | +} |
| | 335 | + |
| 230 | func (d *Docker) StreamLogs(_ context.Context, jobID int64) (<-chan LogChunk, error) { | 336 | func (d *Docker) StreamLogs(_ context.Context, jobID int64) (<-chan LogChunk, error) { |
| 231 | return d.ensureStream(jobID), nil | 337 | return d.ensureStream(jobID), nil |
| 232 | } | 338 | } |