@@ -8,11 +8,13 @@ import ( |
| 8 | 8 | "errors" |
| 9 | 9 | "fmt" |
| 10 | 10 | "io" |
| 11 | + "log/slog" |
| 11 | 12 | "os" |
| 12 | 13 | "os/exec" |
| 13 | 14 | "path" |
| 14 | 15 | "regexp" |
| 15 | 16 | "sort" |
| 17 | + "strconv" |
| 16 | 18 | "strings" |
| 17 | 19 | "sync" |
| 18 | 20 | "time" |
@@ -27,6 +29,19 @@ var ( |
| 27 | 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 | 45 | type CommandRunner interface { |
| 31 | 46 | Run(ctx context.Context, name string, args []string, stdout, stderr io.Writer) error |
| 32 | 47 | } |
@@ -46,6 +61,9 @@ type DockerConfig struct { |
| 46 | 61 | Network string |
| 47 | 62 | Memory string |
| 48 | 63 | CPUs string |
| 64 | + SeccompProfile string |
| 65 | + User string |
| 66 | + PidsLimit int |
| 49 | 67 | LogChunkBytes int |
| 50 | 68 | LogFlushInterval time.Duration |
| 51 | 69 | StepLogLimit int64 |
@@ -53,6 +71,7 @@ type DockerConfig struct { |
| 53 | 71 | Stderr io.Writer |
| 54 | 72 | Runner CommandRunner |
| 55 | 73 | MaskValues []string |
| 74 | + Logger *slog.Logger |
| 56 | 75 | } |
| 57 | 76 | |
| 58 | 77 | type Docker struct { |
@@ -84,6 +103,18 @@ func NewDocker(cfg DockerConfig) *Docker { |
| 84 | 103 | if cfg.Runner == nil { |
| 85 | 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 | 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 | 182 | if strings.TrimSpace(step.Run) == "" { |
| 152 | 183 | return nil |
| 153 | 184 | } |
| 154 | | - args, err := d.dockerArgs(job, step) |
| 185 | + invocation, err := d.dockerInvocation(job, step) |
| 155 | 186 | if err != nil { |
| 156 | 187 | return err |
| 157 | 188 | } |
| 189 | + d.logStep(ctx, "runner step starting", job, step, invocation, "") |
| 158 | 190 | writer := d.newStepLogWriter(ctx, job.ID, step.ID, job.MaskValues) |
| 159 | 191 | out := io.MultiWriter(d.cfg.Stdout, writer) |
| 160 | 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 | 195 | if closeErr := writer.Close(); closeErr != nil { |
| 163 | 196 | return fmt.Errorf("runner engine: step %q failed: %w", stepLabel(step), errors.Join(err, closeErr)) |
| 164 | 197 | } |
| 165 | 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 | 201 | if err := writer.Close(); err != nil { |
| 168 | 202 | return fmt.Errorf("runner engine: flush step %q logs: %w", stepLabel(step), err) |
| 169 | 203 | } |
| 170 | 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 | 219 | workdir, err := containerWorkdir(step.WorkingDirectory) |
| 175 | 220 | if err != nil { |
| 176 | | - return nil, err |
| 221 | + return dockerInvocation{}, err |
| 177 | 222 | } |
| 178 | 223 | image := strings.TrimSpace(job.Image) |
| 179 | 224 | if image == "" { |
| 180 | 225 | image = d.cfg.DefaultImage |
| 181 | 226 | } |
| 182 | 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 | 230 | rendered, err := runnerexec.RenderStep(runnerexec.StepInput{ |
| 186 | 231 | Run: step.Run, |
@@ -189,7 +234,11 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) { |
| 189 | 234 | Context: expressionContext(job), |
| 190 | 235 | }) |
| 191 | 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 | 243 | args := []string{ |
| 195 | 244 | "run", |
@@ -197,18 +246,58 @@ func (d *Docker) dockerArgs(job Job, step Step) ([]string, error) { |
| 197 | 246 | "--network=" + d.cfg.Network, |
| 198 | 247 | "--memory=" + d.cfg.Memory, |
| 199 | 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 | 261 | "--workdir=" + workdir, |
| 201 | | - "-v", job.WorkspaceDir + ":/workspace", |
| 262 | + "--mount", "type=bind,src=" + job.WorkspaceDir + ",dst=/workspace,rw", |
| 202 | 263 | } |
| 203 | 264 | env, err := validateEnv(rendered.Env) |
| 204 | 265 | if err != nil { |
| 205 | | - return nil, err |
| 266 | + return dockerInvocation{}, err |
| 206 | 267 | } |
| 207 | 268 | for _, key := range sortedKeys(env) { |
| 208 | 269 | args = append(args, "-e", key+"="+env[key]) |
| 209 | 270 | } |
| 210 | 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 | 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 | 336 | func (d *Docker) StreamLogs(_ context.Context, jobID int64) (<-chan LogChunk, error) { |
| 231 | 337 | return d.ensureStream(jobID), nil |
| 232 | 338 | } |