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