This repository has no description
0

Configure Feed

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

core / spindle / engines / microvm / engine.go
19 kB 627 lines
1package microvm 2 3import ( 4 "context" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "log/slog" 10 "net/http" 11 "os" 12 "path/filepath" 13 "slices" 14 "strings" 15 "sync" 16 "sync/atomic" 17 "time" 18 19 "gopkg.in/yaml.v3" 20 21 "tangled.org/core/api/tangled" 22 "tangled.org/core/log" 23 "tangled.org/core/spindle/agentproto" 24 agentv1 "tangled.org/core/spindle/agentproto/gen" 25 "tangled.org/core/spindle/config" 26 "tangled.org/core/spindle/db" 27 "tangled.org/core/spindle/engine" 28 "tangled.org/core/spindle/models" 29 "tangled.org/core/spindle/secrets" 30) 31 32const ( 33 guestWorkDir = "/workspace/repo" 34 guestBasePATH = "/run/current-system/sw/bin:/nix/var/nix/profiles/default/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 35 guestDevShellEnvPath = "/run/spindle/devshell-env.sh" 36 activationStepAction = "activate-config" 37 agentAcceptTimeout = 2 * time.Minute 38 agentHandshakeTimeout = 30 * time.Second 39 cacheDrainTimeout = 5 * time.Minute 40 vmShutdownTimeout = 10 * time.Second 41 guestTimeoutGrace = 5 * time.Second 42) 43 44type cleanupFunc func(context.Context) error 45 46type Engine struct { 47 l *slog.Logger 48 cfg *config.Config 49 db *db.DB 50 agentMu sync.Mutex 51 agent *agentHub 52 scheduler *engine.ResourceScheduler[Resources] 53 cgroupParent *CgroupParent 54 55 cleanupMu sync.Mutex 56 cleanup map[string][]cleanupFunc 57} 58 59type Step struct { 60 name string 61 kind models.StepKind 62 command string 63 environment map[string]string 64 action string 65 config manifestConfig 66 configKey string 67} 68 69func (s Step) Name() string { return s.name } 70func (s Step) Command() string { return s.command } 71func (s Step) Kind() models.StepKind { return s.kind } 72 73func New(ctx context.Context, cfg *config.Config, d *db.DB) (*Engine, error) { 74 l := log.FromContext(ctx).With("component", "engine.microvm") 75 budget, max, agingThreshold := newVMBudgetConfig(cfg.MicroVMPipelines) 76 l.Info("initialized microVM workflow budget", "budget", budget.String(), "maxWorkflow", max.String(), "agingThreshold", agingThreshold) 77 78 var cgroupParent *CgroupParent 79 var err error 80 if cfg.MicroVMPipelines.EnableCgroups { 81 cgroupParent, err = initCgroupParent(cfg.MicroVMPipelines.CgroupParent, cfg.MicroVMPipelines.CgroupSupervisorMemoryMinMiB, l) 82 if err != nil { 83 return nil, err 84 } 85 } 86 87 return &Engine{ 88 l: l, 89 cfg: cfg, 90 db: d, 91 scheduler: engine.NewResourceScheduler(budget, max, agingThreshold), 92 cgroupParent: cgroupParent, 93 cleanup: make(map[string][]cleanupFunc), 94 }, nil 95} 96 97func (e *Engine) ensureAgentHub() (*agentHub, error) { 98 e.agentMu.Lock() 99 defer e.agentMu.Unlock() 100 101 if e.agent != nil { 102 return e.agent, nil 103 } 104 105 port := e.cfg.MicroVMPipelines.AgentPort 106 if port == 0 { 107 port = agentproto.DefaultPort 108 } 109 agent, err := newAgentHub(port, e.l) 110 if err != nil { 111 return nil, err 112 } 113 e.agent = agent 114 return agent, nil 115} 116 117func (e *Engine) InitWorkflow(twf tangled.Pipeline_Workflow, tpl tangled.Pipeline) (*models.Workflow, error) { 118 swf := &models.Workflow{} 119 var dwf manifestWorkflow 120 121 if err := engine.DescribeManifestError(twf.Raw, manifestWorkflow{}); err != nil { 122 return nil, err 123 } 124 if err := yaml.Unmarshal([]byte(twf.Raw), &dwf); err != nil { 125 return nil, err 126 } 127 128 for _, dstep := range dwf.Steps { 129 swf.Steps = append(swf.Steps, Step{ 130 name: dstep.Name, 131 kind: models.StepKindUser, 132 command: dstep.Command, 133 environment: dstep.Environment, 134 }) 135 } 136 swf.Name = twf.Name 137 swf.Environment = dwf.Environment 138 139 if tpl.TriggerMetadata != nil { 140 if clone := models.BuildCloneStep(twf, *tpl.TriggerMetadata, e.cfg.Server.Dev); clone.Command() != "" { 141 swf.Steps = append([]models.Step{clone}, swf.Steps...) 142 } 143 } 144 145 imageSpec, imageSpecPath, imageName, err := e.resolveImage(dwf.Image) 146 if err != nil { 147 return nil, err 148 } 149 configKey := "" 150 config := manifestConfig{ 151 Services: dwf.Services, 152 Virtualisation: dwf.Virtualisation, 153 Dependencies: dwf.Dependencies, 154 Registry: dwf.Registry, 155 } 156 if config.Enabled() { 157 if !imageSpec.SupportsConfigActivation() { 158 return nil, fmt.Errorf( 159 "microVM image %q is not a NixOS image: services, virtualisation, dependencies and registry workflow options require a NixOS image", 160 imageName, 161 ) 162 } 163 // the cached toplevel is guest-asserted, so the cache key has to 164 // be per-repo. a global key lets one tenant poison the activation 165 // of another tenant's identical config 166 repoDid := "" 167 if md := tpl.TriggerMetadata; md != nil && md.Repo != nil { 168 repoDid = md.Repo.Did 169 } 170 var err error 171 configKey, err = buildConfigKey(imageSpec, config, repoDid) 172 if err != nil { 173 return nil, fmt.Errorf("build config key: %w", err) 174 } 175 activationStep := Step{ 176 name: "NixOS config activation", 177 kind: models.StepKindSystem, 178 command: "activate nixos config", 179 action: activationStepAction, 180 config: config, 181 configKey: configKey, 182 } 183 184 insertAt := 0 185 if len(swf.Steps) > 0 && swf.Steps[0].Kind() == models.StepKindSystem { 186 insertAt = 1 187 } 188 swf.Steps = append(swf.Steps, nil) 189 copy(swf.Steps[insertAt+1:], swf.Steps[insertAt:]) 190 swf.Steps[insertAt] = activationStep 191 } 192 193 cacheURLs, cacheKeys, err := workflowCaches(dwf.Caches) 194 if err != nil { 195 return nil, err 196 } 197 198 swf.Data = &workflowState{ 199 ImageSpec: imageSpec, 200 ImageSpecPath: imageSpecPath, 201 Config: config, 202 ConfigKey: configKey, 203 Image: imageName, 204 CacheReadURLs: cacheURLs, 205 CacheTrustedPublicKeys: cacheKeys, 206 NixOSToplevelCache: newNixOSToplevelCacheStore(e.db), 207 } 208 return swf, nil 209} 210 211func (e *Engine) SetupWorkflow(ctx context.Context, wid models.WorkflowId, wf *models.Workflow, wfLogger models.WorkflowLogger) (err error) { 212 l := e.l.With("workflow", wid) 213 setupStep := Step{name: "microVM setup", kind: models.StepKindSystem} 214 215 wfLogger.ControlWriter(-1, setupStep, models.StepStatusStart).Write([]byte{0}) 216 defer wfLogger.ControlWriter(-1, setupStep, models.StepStatusEnd).Write([]byte{0}) 217 218 category := "Failed to setup VM" 219 defer func() { 220 if err != nil { 221 err = fmt.Errorf("%s:\n%w", category, err) 222 } 223 }() 224 225 state, ok := wf.Data.(*workflowState) 226 if !ok || state == nil { 227 return fmt.Errorf("workflow state is not initialized") 228 } 229 230 cid, err := AllocateCID() 231 if err != nil { 232 return err 233 } 234 agent, err := e.ensureAgentHub() 235 if err != nil { 236 return err 237 } 238 connCh, unregister, err := agent.expect(cid) 239 if err != nil { 240 return err 241 } 242 defer unregister() 243 244 workDirBase := e.cfg.MicroVMPipelines.OverlayDir 245 if workDirBase == "" { 246 workDirBase = os.TempDir() 247 } 248 workDir, err := os.MkdirTemp(workDirBase, "spindle-microvm-"+wid.String()+"-*") 249 if err != nil { 250 return fmt.Errorf("create workflow microVM directory: %w", err) 251 } 252 state.WorkDir = workDir 253 254 setupDone := false 255 defer func() { 256 if setupDone { 257 return 258 } 259 if detail := VMCrashLog(state.VM); detail != "" { 260 l.Error("microVM setup failed", "detail", detail) 261 } 262 if err := e.cleanupState(context.Background(), wid, state); err != nil { 263 l.Error("failed to cleanup failed setup", "error", err) 264 } 265 }() 266 267 upstreams, err := BuildCacheUpstreams(e.cfg.NixCache.ReadURLs, state.CacheReadURLs) 268 if err != nil { 269 return err 270 } 271 readCache, err := StartReadCacheProxy(ctx, cid, upstreams, l) 272 if err != nil { 273 return err 274 } 275 state.ReadCache = readCache 276 stagingDir := filepath.Join(workDir, "upload-cache") 277 uploadCache, err := StartUploadCacheProxy(ctx, cid, e.cfg.NixCache.UploadURL, upstreams, stagingDir, l) 278 if err != nil { 279 return err 280 } 281 state.UploadCache = uploadCache 282 dnsProxy, err := StartDNSProxy(ctx, cid, l) 283 if err != nil { 284 return err 285 } 286 state.DNSProxy = dnsProxy 287 288 port := e.cfg.MicroVMPipelines.AgentPort 289 if port == 0 { 290 port = agentproto.DefaultPort 291 } 292 state.ImageSpec.BootArgs = fmt.Sprintf("%s shuttle.vsock_port=%d", state.ImageSpec.BootArgs, port) 293 294 fmt.Fprintf(wfLogger.DataWriter(-1, "stdout"), "starting microVM image %s\n", state.Image) 295 l.Info("starting microVM workflow", "image", state.Image, "imageSpec", state.ImageSpecPath, "cid", cid, "workDir", workDir) 296 297 var vm VMHandle 298 vm, err = StartVM(ctx, VMConfig{ 299 Image: state.ImageSpec, 300 CID: cid, 301 EnableKVM: e.cfg.MicroVMPipelines.EnableKVM, 302 WorkDir: workDir, 303 Cgroup: e.cgroupLimits(wid, state.ImageSpec), 304 Dev: e.cfg.Server.Dev, 305 }, l) 306 if err != nil { 307 return err 308 } 309 state.VM = vm 310 311 category = "Failed to connect to agent" 312 313 acceptCtx, cancelAccept := context.WithTimeout(ctx, agentAcceptTimeout) 314 defer cancelAccept() 315 conn, err := waitAgentConn(acceptCtx, connCh) 316 if err != nil { 317 return err 318 } 319 320 agentSession := NewAgentSession(conn, l) 321 initCtx, cancelInit := context.WithTimeout(ctx, agentHandshakeTimeout) 322 defer cancelInit() 323 if err := agentSession.Init(initCtx, &agentv1.Init{ 324 JobId: wid.String(), 325 CacheTrustedPublicKeys: append(slices.Clone(e.cfg.NixCache.TrustedPublicKeys), state.CacheTrustedPublicKeys...), 326 CacheReadProxyPort: readCache.Port(), 327 CacheUploadProxyPort: uploadCache.Port(), 328 DnsProxyPort: dnsProxy.Port(), 329 }); err != nil { 330 _ = agentSession.Close() 331 return err 332 } 333 state.Agent = agentSession 334 wf.Data = state 335 336 e.registerCleanup(wid, func(ctx context.Context) error { 337 return e.cleanupState(ctx, wid, state) 338 }) 339 setupDone = true 340 341 fmt.Fprintf(wfLogger.DataWriter(-1, "stdout"), 342 "agent connected; serial log: %s\n", vm.Logs().Serial, 343 ) 344 return nil 345} 346 347func applyDepsSource(command string) string { 348 return fmt.Sprintf( 349 // check if it exists because not all images have this 350 `if [ -f %s ]; then . %s; export PATH="$PATH:%s"; fi; %s`, 351 guestDevShellEnvPath, guestDevShellEnvPath, guestBasePATH, command, 352 ) 353} 354 355func (e *Engine) RunStep(ctx context.Context, wid models.WorkflowId, w *models.Workflow, idx int, secrets []secrets.UnlockedSecret, wfLogger models.WorkflowLogger) error { 356 state, ok := w.Data.(*workflowState) 357 if !ok || state == nil || state.Agent == nil { 358 return fmt.Errorf("microVM workflow is not connected to agent") 359 } 360 361 stderr := wfLogger.DataWriter(idx, "stderr") 362 363 execCtx, vmExited, cancelWatch := watchVMExit(ctx, state.VM) 364 defer cancelWatch() 365 366 step := w.Steps[idx] 367 if s, ok := step.(Step); ok && s.action == activationStepAction { 368 err := e.activateConfig(execCtx, wid, state, s, wfLogger.DataWriter(idx, "stdout")) 369 return e.classifyStepError(ctx, wid, step, state, stderr, vmExited, "Failed to activate config", err) 370 } 371 env := []string{ 372 "HOME=/workspace", 373 "LOGNAME=" + guestWorkflowUser, 374 "PATH=" + guestBasePATH, 375 "USER=" + guestWorkflowUser, 376 } 377 for k, v := range w.Environment { 378 env = append(env, k+"="+v) 379 } 380 for _, s := range secrets { 381 env = append(env, s.Key+"="+s.Value) 382 } 383 if s, ok := step.(Step); ok { 384 for k, v := range s.environment { 385 env = append(env, k+"="+v) 386 } 387 } 388 389 stdout := wfLogger.DataWriter(idx, "stdout") 390 exitCode, err := state.Agent.Exec(execCtx, AgentExec{ 391 ID: fmt.Sprintf("%s-%d", wid.String(), idx), 392 ExecStart: &agentv1.ExecStart{ 393 Argv: []string{state.ImageSpec.Shell, "-lc", applyDepsSource(step.Command())}, 394 Env: env, 395 Cwd: guestWorkDir, 396 User: guestWorkflowUser, 397 // timeout not set here, Exec will fill it 398 }, 399 Stdout: stdout, 400 Stderr: stderr, 401 }) 402 if err != nil { 403 return e.classifyStepError(ctx, wid, step, state, stderr, vmExited, "User step error", err) 404 } 405 406 if exitCode != 0 { 407 e.l.Debug("step exited non-zero", "workflow", wid, "step", step.Name(), "exitCode", exitCode) 408 return fmt.Errorf("User step error: exited with code %d", exitCode) 409 } 410 return nil 411} 412 413// reads the vm serial logs so we report the tail of that as an error instead of 414// just "guest agent connection lost: EOF" 415func (e *Engine) classifyStepError(ctx context.Context, wid models.WorkflowId, step models.Step, state *workflowState, stderr io.Writer, vmExited *atomic.Bool, category string, err error) error { 416 if err == nil { 417 return nil 418 } 419 l := e.l.With("workflow", wid, "step", step.Name()) 420 421 if vmExited != nil && vmExited.Load() { 422 reason := "microVM exited unexpectedly" 423 oom := state.VM != nil && state.VM.OOMKilled() 424 if oom { 425 reason = "microVM killed by OOM (cgroup memory limit exceeded)" 426 } 427 if detail := VMCrashLog(state.VM); detail != "" { 428 fmt.Fprintf(stderr, "%s:\n%s\n", reason, detail) 429 l.Error(reason, "oom", oom, "detail", detail) 430 } else { 431 fmt.Fprintln(stderr, reason) 432 l.Error(reason, "oom", oom) 433 } 434 return fmt.Errorf("%s:\n%w", category, errors.New(reason+"; see workflow logs for serial output")) 435 } 436 437 if errors.Is(err, errGuestTimedOut) || ctx.Err() != nil { 438 l.Debug("step timed out", "guestReported", errors.Is(err, errGuestTimedOut)) 439 return engine.ErrTimedOut 440 } 441 442 // the agent connection dropped while qemu stayed up (eg. the guest kernel 443 // OOM-killed the agent or a guest panic), so surface serial logs, those 444 // will be more helpful. 445 var crashErr error 446 if detail := VMCrashLog(state.VM); detail != "" { 447 fmt.Fprintf(stderr, "step failed (%v):\n%s\n", err, detail) 448 l.Error("step failed", "error", err, "detail", detail) 449 if parsedErr, ok := ParseCrashLog(detail); ok { 450 crashErr = parsedErr 451 } else { 452 if strings.Contains(err.Error(), "guest exec error:") { 453 crashErr = err 454 } else { 455 crashErr = fmt.Errorf("guest agent connection lost: %w", err) 456 } 457 } 458 } else { 459 l.Error("step failed", "error", err) 460 crashErr = err 461 } 462 return fmt.Errorf("%s:\n%w", category, crashErr) 463} 464 465func (e *Engine) activateConfig(ctx context.Context, wid models.WorkflowId, state *workflowState, step Step, out io.Writer) error { 466 cfg := step.config 467 if !cfg.Enabled() { 468 return nil 469 } 470 471 configKey := step.configKey 472 if configKey == "" { 473 configKey = state.ConfigKey 474 } 475 476 userConfigJSON, err := json.Marshal(cfg) 477 if err != nil { 478 return fmt.Errorf("encode user config: %w", err) 479 } 480 481 var cachedToplevel string 482 if configKey != "" { 483 if record, ok, err := state.NixOSToplevelCache.Lookup(configKey); err != nil { 484 return err 485 } else if ok { 486 // todo(dawn): we should probably use gc roots to eliminate TOCTOU 487 // the spindle will have to manage the gc roots, and for remote we have to 488 // ssh in to the host and add / remove gc root. 489 // we need to have this check anyway since the only check http caches can 490 // use is this one, since we cant manage gc roots there... 491 if e.anyCacheHasPath(ctx, state, record.Toplevel) { 492 cachedToplevel = record.Toplevel 493 fmt.Fprintf(out, "realizing cached NixOS config %s\n", cachedToplevel) 494 } 495 } 496 } 497 if cachedToplevel == "" { 498 fmt.Fprintf(out, "building NixOS config from user config\n") 499 } 500 501 baseHash, err := BaseConfigHash(state.ImageSpec) 502 if err != nil { 503 return fmt.Errorf("calculate base config hash: %w", err) 504 } 505 506 result, err := state.Agent.ActivateConfig(ctx, fmt.Sprintf("%s-config", wid.String()), &agentv1.ActivateConfig{ 507 ConfigKey: configKey, 508 BaseConfigHash: baseHash, 509 UserConfig: string(userConfigJSON), 510 Toplevel: cachedToplevel, 511 }, out) 512 if err != nil { 513 return err 514 } 515 fmt.Fprintf(out, "activated NixOS config toplevel %s\n", result.Toplevel) 516 517 if cachedToplevel != "" || configKey == "" { 518 return nil 519 } 520 if e.cfg.NixCache.UploadURL == "" { 521 e.l.Warn("not committing config cache metadata: no upload URL configured", "workflow", wid, "configKey", configKey, "toplevel", result.Toplevel) 522 return nil 523 } 524 525 if err := e.drainNixCache(ctx, state); err != nil { 526 // a partial upload would leave the cache unable to realize this toplevel, 527 // so skip the metadata commit rather than poison it with an un-realizable 528 // key. the config still activated fine, so don't fail the workflow. 529 e.l.Warn("cache drain failed; skipping config cache metadata commit", "workflow", wid, "configKey", configKey, "toplevel", result.Toplevel, "error", err) 530 return nil 531 } 532 if err := state.NixOSToplevelCache.Commit(configKey, result.Toplevel); err != nil { 533 return err 534 } 535 fmt.Fprintf(out, "committed config cache metadata %s -> %s\n", configKey, result.Toplevel) 536 return nil 537} 538 539func (e *Engine) anyCacheHasPath(ctx context.Context, state *workflowState, storePath string) bool { 540 upstreams, err := BuildCacheUpstreams(e.cfg.NixCache.ReadURLs, state.CacheReadURLs) 541 if err != nil { 542 e.l.Warn("config cache check: build upstreams failed; treating as absent", "path", storePath, "error", err) 543 return false 544 } 545 if len(upstreams) == 0 { 546 return false 547 } 548 hash, _, err := parseStorePath(storePath) 549 if err != nil { 550 e.l.Warn("config cache check: invalid toplevel path; treating as absent", "path", storePath, "error", err) 551 return false 552 } 553 req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://upstream/"+hash+".narinfo", nil) 554 if err != nil { 555 e.l.Warn("config cache check: build request failed; treating as absent", "path", storePath, "error", err) 556 return false 557 } 558 resp, err := newNarinfoExistenceTransport(upstreams, e.l).RoundTrip(req) 559 if err != nil { 560 e.l.Warn("config cache check: narinfo probe failed; treating as absent", "path", storePath, "error", err) 561 return false 562 } 563 defer resp.Body.Close() 564 _, _ = io.Copy(io.Discard, resp.Body) 565 return resp.StatusCode == http.StatusOK 566} 567 568func (e *Engine) DestroyWorkflow(ctx context.Context, wid models.WorkflowId) error { 569 fns := e.drainCleanups(wid) 570 571 var cleanupErr error 572 for i := len(fns) - 1; i >= 0; i-- { 573 if err := fns[i](ctx); err != nil { 574 e.l.Error("failed to cleanup workflow resource", "workflowId", wid, "error", err) 575 cleanupErr = errors.Join(cleanupErr, err) 576 } 577 } 578 return cleanupErr 579} 580 581func (e *Engine) FinalizeWorkflow(ctx context.Context, wid models.WorkflowId, w *models.Workflow, wfLogger models.WorkflowLogger) error { 582 return nil 583} 584 585func (e *Engine) WorkflowTimeout() time.Duration { 586 d, err := time.ParseDuration(e.cfg.MicroVMPipelines.WorkflowTimeout) 587 if err != nil { 588 d = 5 * time.Minute 589 } 590 return d + guestTimeoutGrace 591} 592 593func (e *Engine) registerCleanup(wid models.WorkflowId, fn cleanupFunc) { 594 e.cleanupMu.Lock() 595 defer e.cleanupMu.Unlock() 596 key := wid.String() 597 e.cleanup[key] = append(e.cleanup[key], fn) 598} 599 600func (e *Engine) drainCleanups(wid models.WorkflowId) []cleanupFunc { 601 e.cleanupMu.Lock() 602 defer e.cleanupMu.Unlock() 603 key := wid.String() 604 fns := e.cleanup[key] 605 delete(e.cleanup, key) 606 return fns 607} 608 609func (e *Engine) cgroupLimits(wid models.WorkflowId, spec ImageSpec) CgroupLimits { 610 cfg := e.cfg.MicroVMPipelines 611 cpuQuotaPercent := cfg.CgroupCPUMaxPercent 612 if cpuQuotaPercent == 0 { 613 // a vm can only use its vcpus anyway, but without a cap those 614 // threads still get full host cores and starve every other tenant 615 cpuQuotaPercent = int64(spec.VCPUs) * 100 616 } 617 return CgroupLimits{ 618 Enabled: cfg.EnableCgroups, 619 Parent: e.cgroupParent, 620 Name: "workflow-" + wid.String(), 621 MemoryMaxMiB: resourcesForImage(spec).MemoryMiB, 622 SwapMaxMiB: cfg.CgroupSwapMaxMiB, 623 PidsMax: cfg.CgroupPidsMax, 624 CPUQuotaPercent: cpuQuotaPercent, 625 IOWeight: cfg.CgroupIOWeight, 626 } 627}