This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / spindle / engines / nixery / engine.go
13 kB 479 lines
1package nixery 2 3import ( 4 "bufio" 5 "context" 6 "errors" 7 "fmt" 8 "io" 9 "log/slog" 10 "path" 11 "runtime" 12 "sync" 13 "time" 14 15 "github.com/docker/docker/api/types/container" 16 "github.com/docker/docker/api/types/image" 17 "github.com/docker/docker/api/types/mount" 18 "github.com/docker/docker/api/types/network" 19 "github.com/docker/docker/client" 20 "github.com/docker/docker/pkg/stdcopy" 21 "gopkg.in/yaml.v3" 22 "tangled.org/core/api/tangled" 23 "tangled.org/core/log" 24 "tangled.org/core/spindle/config" 25 "tangled.org/core/spindle/engine" 26 "tangled.org/core/spindle/models" 27 "tangled.org/core/spindle/secrets" 28) 29 30const ( 31 workspaceDir = "/tangled/workspace" 32 homeDir = "/tangled/home" 33) 34 35type cleanupFunc func(context.Context) error 36 37type Engine struct { 38 docker client.APIClient 39 l *slog.Logger 40 cfg *config.Config 41 42 cleanupMu sync.Mutex 43 cleanup map[string][]cleanupFunc 44} 45 46type Step struct { 47 name string 48 kind models.StepKind 49 command string 50 environment map[string]string 51} 52 53func (s Step) Name() string { 54 return s.name 55} 56 57func (s Step) Command() string { 58 return s.command 59} 60 61func (s Step) Kind() models.StepKind { 62 return s.kind 63} 64 65// setupSteps get added to start of Steps 66type setupSteps []models.Step 67 68// addStep adds a step to the beginning of the workflow's steps. 69func (ss *setupSteps) addStep(step models.Step) { 70 *ss = append(*ss, step) 71} 72 73type addlFields struct { 74 image string 75 container string 76} 77 78func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 79 swf := &models.Workflow{} 80 addl := addlFields{} 81 82 dwf := &struct { 83 Steps []struct { 84 Command string `yaml:"command"` 85 Name string `yaml:"name"` 86 Environment map[string]string `yaml:"environment"` 87 } `yaml:"steps"` 88 Dependencies map[string][]string `yaml:"dependencies"` 89 Environment map[string]string `yaml:"environment"` 90 }{} 91 err := yaml.Unmarshal([]byte(twf.Raw), &dwf) 92 if err != nil { 93 return nil, err 94 } 95 96 for _, dstep := range dwf.Steps { 97 sstep := Step{} 98 sstep.environment = dstep.Environment 99 sstep.command = dstep.Command 100 sstep.name = dstep.Name 101 sstep.kind = models.StepKindUser 102 swf.Steps = append(swf.Steps, sstep) 103 } 104 swf.Name = twf.Name 105 swf.Environment = dwf.Environment 106 addl.image = workflowImage(dwf.Dependencies, e.cfg.NixeryPipelines.Nixery) 107 108 setup := &setupSteps{} 109 110 setup.addStep(nixConfStep()) 111 setup.addStep(models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev)) 112 // this step could be empty 113 if s := dependencyStep(dwf.Dependencies); s != nil { 114 setup.addStep(*s) 115 } 116 117 // append setup steps in order to the start of workflow steps 118 swf.Steps = append(*setup, swf.Steps...) 119 swf.Data = addl 120 121 return swf, nil 122} 123 124func (e *Engine) WorkflowTimeout() time.Duration { 125 workflowTimeoutStr := e.cfg.NixeryPipelines.WorkflowTimeout 126 workflowTimeout, err := time.ParseDuration(workflowTimeoutStr) 127 if err != nil { 128 e.l.Error("failed to parse workflow timeout", "error", err, "timeout", workflowTimeoutStr) 129 workflowTimeout = 5 * time.Minute 130 } 131 132 return workflowTimeout 133} 134 135func workflowImage(deps map[string][]string, nixery string) string { 136 var dependencies string 137 for reg, ds := range deps { 138 if reg == "nixpkgs" { 139 dependencies = path.Join(ds...) 140 } 141 } 142 143 // load defaults from somewhere else 144 dependencies = path.Join(dependencies, "bash", "git", "coreutils", "nix") 145 146 if runtime.GOARCH == "arm64" { 147 dependencies = path.Join("arm64", dependencies) 148 } 149 150 return path.Join(nixery, dependencies) 151} 152 153func New(ctx context.Context, cfg *config.Config) (*Engine, error) { 154 dcli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 155 if err != nil { 156 return nil, err 157 } 158 159 l := log.FromContext(ctx).With("component", "spindle") 160 161 e := &Engine{ 162 docker: dcli, 163 l: l, 164 cfg: cfg, 165 } 166 167 e.cleanup = make(map[string][]cleanupFunc) 168 169 return e, nil 170} 171 172func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) error { 173 /// -------------------------INITIAL SETUP------------------------------------------ 174 l := e.l.With("workflow", wid) 175 l.Info("setting up workflow") 176 177 setupStep := Step{ 178 name: "nixery image pull", 179 kind: models.StepKindSystem, 180 } 181 setupStepIdx := -1 182 183 wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusStart).Write([]byte{0}) 184 defer wfLogger.ControlWriter(setupStepIdx, setupStep, models.StepStatusEnd).Write([]byte{0}) 185 186 /// -------------------------NETWORK CREATION--------------------------------------- 187 _, err := e.docker.NetworkCreate(ctx, networkName(wid), network.CreateOptions{ 188 Driver: "bridge", 189 }) 190 if err != nil { 191 return err 192 } 193 194 e.registerCleanup(wid, func(ctx context.Context) error { 195 if err := e.docker.NetworkRemove(ctx, networkName(wid)); err != nil { 196 return fmt.Errorf("removing network: %w", err) 197 } 198 return nil 199 }) 200 201 /// -------------------------IMAGE PULL--------------------------------------------- 202 addl := wf.Data.(addlFields) 203 l.Info("pulling image", "image", addl.image) 204 fmt.Fprintf( 205 wfLogger.DataWriter(setupStepIdx, "stdout"), 206 "pulling image: %s", 207 addl.image, 208 ) 209 210 reader, err := e.docker.ImagePull(ctx, addl.image, image.PullOptions{}) 211 if err != nil { 212 l.Error("pipeline image pull failed!", "error", err.Error()) 213 fmt.Fprintf(wfLogger.DataWriter(setupStepIdx, "stderr"), "image pull failed: %s", err) 214 return fmt.Errorf("pulling image: %w", err) 215 } 216 defer reader.Close() 217 218 scanner := bufio.NewScanner(reader) 219 for scanner.Scan() { 220 line := scanner.Text() 221 wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte(line)) 222 l.Info("image pull progress", "stdout", line) 223 } 224 225 /// -------------------------CONTAINER CREATION------------------------------------- 226 l.Info("creating container") 227 wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("creating container...")) 228 229 resp, err := e.docker.ContainerCreate(ctx, &container.Config{ 230 Image: addl.image, 231 Cmd: []string{"cat"}, 232 OpenStdin: true, // so cat stays alive :3 233 Tty: false, 234 Hostname: "spindle", 235 WorkingDir: workspaceDir, 236 Labels: map[string]string{ 237 "sh.tangled.pipeline/workflow_id": wid.String(), 238 }, 239 // TODO(winter): investigate whether environment variables passed here 240 // get propagated to ContainerExec processes 241 }, &container.HostConfig{ 242 Mounts: []mount.Mount{ 243 { 244 Type: mount.TypeTmpfs, 245 Target: "/tmp", 246 ReadOnly: false, 247 TmpfsOptions: &mount.TmpfsOptions{ 248 Mode: 0o1777, // world-writable sticky bit 249 Options: [][]string{ 250 {"exec"}, 251 }, 252 }, 253 }, 254 }, 255 ReadonlyRootfs: false, 256 CapDrop: []string{"ALL"}, 257 CapAdd: []string{"CAP_DAC_OVERRIDE", "CAP_CHOWN", "CAP_FOWNER", "CAP_SETUID", "CAP_SETGID"}, 258 SecurityOpt: []string{"no-new-privileges"}, 259 ExtraHosts: []string{"host.docker.internal:host-gateway"}, 260 Resources: container.Resources{ 261 Memory: e.cfg.NixeryPipelines.MaxJobMemoryMB * 1024 * 1024, 262 }, 263 }, nil, nil, "") 264 if err != nil { 265 fmt.Fprintf( 266 wfLogger.DataWriter(setupStepIdx, "stderr"), 267 "container creation failed: %s", 268 err, 269 ) 270 return fmt.Errorf("creating container: %w", err) 271 } 272 273 e.registerCleanup(wid, func(ctx context.Context) error { 274 if err := e.docker.ContainerStop(ctx, resp.ID, container.StopOptions{}); err != nil { 275 return fmt.Errorf("stopping container: %w", err) 276 } 277 278 err := e.docker.ContainerRemove(ctx, resp.ID, container.RemoveOptions{ 279 RemoveVolumes: true, 280 RemoveLinks: false, 281 Force: false, 282 }) 283 if err != nil { 284 return fmt.Errorf("removing container: %w", err) 285 } 286 287 return nil 288 }) 289 290 /// -------------------------CONTAINER START---------------------------------------- 291 wfLogger.DataWriter(setupStepIdx, "stdout").Write([]byte("starting container...")) 292 if err := e.docker.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { 293 return fmt.Errorf("starting container: %w", err) 294 } 295 296 mkExecResp, err := e.docker.ContainerExecCreate(ctx, resp.ID, container.ExecOptions{ 297 Cmd: []string{"mkdir", "-p", workspaceDir, homeDir}, 298 AttachStdout: true, // NOTE(winter): pretty sure this will make it so that when stdout read is done below, mkdir is done. maybe?? 299 AttachStderr: true, // for good measure, backed up by docker/cli ("If -d is not set, attach to everything by default") 300 }) 301 if err != nil { 302 return err 303 } 304 305 // This actually *starts* the command. Thanks, Docker! 306 execResp, err := e.docker.ContainerExecAttach(ctx, mkExecResp.ID, container.ExecAttachOptions{}) 307 if err != nil { 308 return err 309 } 310 defer execResp.Close() 311 312 // This is apparently best way to wait for the command to complete. 313 _, err = io.ReadAll(execResp.Reader) 314 if err != nil { 315 return err 316 } 317 318 /// -----------------------------------FINISH--------------------------------------- 319 execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 320 if err != nil { 321 return err 322 } 323 324 if execInspectResp.ExitCode != 0 { 325 return fmt.Errorf("mkdir exited with exit code %d", execInspectResp.ExitCode) 326 } else if execInspectResp.Running { 327 return errors.New("mkdir is somehow still running??") 328 } 329 330 addl.container = resp.ID 331 wf.Data = addl 332 333 return nil 334} 335 336func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 337 addl := w.Data.(addlFields) 338 workflowEnvs := ConstructEnvs(w.Environment) 339 // TODO(winter): should SetupWorkflow also have secret access? 340 // IMO yes, but probably worth thinking on. 341 for _, s := range secrets { 342 workflowEnvs.AddEnv(s.Key, s.Value) 343 } 344 345 step := w.Steps[idx] 346 347 select { 348 case <-ctx.Done(): 349 return ctx.Err() 350 default: 351 } 352 353 envs := append(EnvVars(nil), workflowEnvs...) 354 if nixStep, ok := step.(Step); ok { 355 for k, v := range nixStep.environment { 356 envs.AddEnv(k, v) 357 } 358 } 359 360 envs.AddEnv("HOME", homeDir) 361 existingPath := "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 362 envs.AddEnv("PATH", fmt.Sprintf("%s/.nix-profile/bin:/nix/var/nix/profiles/default/bin:%s", homeDir, existingPath)) 363 364 mkExecResp, err := e.docker.ContainerExecCreate(ctx, addl.container, container.ExecOptions{ 365 Cmd: []string{"bash", "-c", step.Command()}, 366 AttachStdout: true, 367 AttachStderr: true, 368 Env: envs, 369 }) 370 if err != nil { 371 return fmt.Errorf("creating exec: %w", err) 372 } 373 374 // start tailing logs in background 375 tailDone := make(chan error, 1) 376 go func() { 377 tailDone <- e.tailStep(ctx, wfLogger, mkExecResp.ID, idx) 378 }() 379 380 select { 381 case <-tailDone: 382 383 case <-ctx.Done(): 384 // cleanup will be handled by DestroyWorkflow, since 385 // Docker doesn't provide an API to kill an exec run 386 // (sure, we could grab the PID and kill it ourselves, 387 // but that's wasted effort) 388 e.l.Warn("step timed out", "step", step.Name()) 389 390 <-tailDone 391 392 return engine.ErrTimedOut 393 } 394 395 select { 396 case <-ctx.Done(): 397 return ctx.Err() 398 default: 399 } 400 401 execInspectResp, err := e.docker.ContainerExecInspect(ctx, mkExecResp.ID) 402 if err != nil { 403 return err 404 } 405 406 if execInspectResp.ExitCode != 0 { 407 inspectResp, err := e.docker.ContainerInspect(ctx, addl.container) 408 if err != nil { 409 return err 410 } 411 412 e.l.Error("workflow failed!", "workflow_id", wid.String(), "exit_code", execInspectResp.ExitCode, "oom_killed", inspectResp.State.OOMKilled) 413 414 if inspectResp.State.OOMKilled { 415 return ErrOOMKilled 416 } 417 return engine.ErrWorkflowFailed 418 } 419 420 return nil 421} 422 423func (e *Engine) tailStep(ctx context.Context, wfLogger models.WorkflowLogger, execID string, stepIdx int) error { 424 if wfLogger == nil { 425 return nil 426 } 427 428 // This actually *starts* the command. Thanks, Docker! 429 logs, err := e.docker.ContainerExecAttach(ctx, execID, container.ExecAttachOptions{}) 430 if err != nil { 431 return err 432 } 433 defer logs.Close() 434 435 _, err = stdcopy.StdCopy( 436 wfLogger.DataWriter(stepIdx, "stdout"), 437 wfLogger.DataWriter(stepIdx, "stderr"), 438 logs.Reader, 439 ) 440 if err != nil && err != io.EOF && !errors.Is(err, context.DeadlineExceeded) { 441 return fmt.Errorf("failed to copy logs: %w", err) 442 } 443 444 return nil 445} 446 447func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 448 fns := e.drainCleanups(wid) 449 450 for _, fn := range fns { 451 if err := fn(ctx); err != nil { 452 e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 453 } 454 } 455 return nil 456} 457 458func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 459 e.cleanupMu.Lock() 460 defer e.cleanupMu.Unlock() 461 462 key := wid.String() 463 e.cleanup[key] = append(e.cleanup[key], fn) 464} 465 466func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 467 e.cleanupMu.Lock() 468 key := wid.String() 469 470 fns := e.cleanup[key] 471 delete(e.cleanup, key) 472 e.cleanupMu.Unlock() 473 474 return fns 475} 476 477func networkName(wid models.WorkflowId) string { 478 return fmt.Sprintf("workflow-network-%s", wid) 479}