This repository has no description
0

Configure Feed

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

core / spindle / engines / microvm / vm.go
11 kB 411 lines
1//go:build linux 2 3package microvm 4 5import ( 6 "context" 7 "crypto/rand" 8 "encoding/binary" 9 "errors" 10 "fmt" 11 "io" 12 "log/slog" 13 "maps" 14 "math" 15 "net" 16 "os" 17 "os/exec" 18 "path/filepath" 19 "regexp" 20 "slices" 21 "strings" 22 "sync/atomic" 23 "time" 24 25 "tangled.org/core/spindle/models" 26) 27 28const ( 29 minGuestCID = 3 30 vmCrashLogTailBytes = 4096 31) 32 33func AllocateCID() (uint32, error) { 34 var data [4]byte 35 if _, err := rand.Read(data[:]); err != nil { 36 return 0, fmt.Errorf("allocate guest CID: %w", err) 37 } 38 return minGuestCID + binary.BigEndian.Uint32(data[:])%60000, nil 39} 40 41func prepareWorkDir(workDir string) error { 42 if workDir == "" { 43 return fmt.Errorf("microvm work directory is required") 44 } 45 if err := os.MkdirAll(workDir, 0o755); err != nil { 46 return fmt.Errorf("create microvm work directory: %w", err) 47 } 48 return nil 49} 50 51func prepareVolumes(ctx context.Context, workDir string, volumes []Volume, mkfsExt4 string) (map[string]string, error) { 52 paths := make(map[string]string, len(volumes)) 53 for _, volume := range volumes { 54 if volume.ReadOnly { 55 return nil, fmt.Errorf("read-only microvm volume %q is not supported yet", volume.Image) 56 } 57 if volume.FSType != "ext4" { 58 return nil, fmt.Errorf("microvm volume %q uses unsupported fsType %q", volume.Image, volume.FSType) 59 } 60 if volume.ImageType != "" && volume.ImageType != "raw" { 61 return nil, fmt.Errorf("microvm volume %q uses unsupported imageType %q", volume.Image, volume.ImageType) 62 } 63 64 path := filepath.Join(workDir, filepath.Base(volume.Image)) 65 if err := createSparseFile(path, volume.SizeMiB); err != nil { 66 return nil, err 67 } 68 noJournal := volume.MountPoint == "/workspace" 69 if err := runMkfsExt4(ctx, mkfsExt4, path, noJournal); err != nil { 70 return nil, err 71 } 72 paths[volume.Image] = path 73 } 74 return paths, nil 75} 76 77func createSparseFile(path string, sizeMiB int64) error { 78 if sizeMiB <= 0 { 79 return fmt.Errorf("sparse file %q size must be positive", path) 80 } 81 if sizeMiB > math.MaxInt64/(1024*1024) { 82 return fmt.Errorf("sparse file %q size is too large", path) 83 } 84 file, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0o600) 85 if err != nil { 86 return fmt.Errorf("create sparse file %q: %w", path, err) 87 } 88 defer file.Close() 89 90 if err := file.Truncate(sizeMiB * 1024 * 1024); err != nil { 91 return fmt.Errorf("resize sparse file %q: %w", path, err) 92 } 93 return nil 94} 95 96func runMkfsExt4(ctx context.Context, mkfsExt4, path string, noJournal bool) error { 97 if mkfsExt4 == "" { 98 return fmt.Errorf("mkfs.ext4 path is required") 99 } 100 args := []string{"-F"} 101 if noJournal { 102 args = append(args, "-O", "^has_journal") 103 } 104 args = append(args, path) 105 106 cmd := exec.CommandContext(ctx, mkfsExt4, args...) 107 output, err := cmd.CombinedOutput() 108 if err != nil { 109 return fmt.Errorf("mkfs.ext4 %q: %w: %s", path, err, strings.TrimSpace(string(output))) 110 } 111 return nil 112} 113 114func createParentedFile(path string) (*os.File, error) { 115 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 116 return nil, fmt.Errorf("create log directory: %w", err) 117 } 118 file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 119 if err != nil { 120 return nil, fmt.Errorf("create log file %q: %w", path, err) 121 } 122 return file, nil 123} 124 125type VMLogs struct { 126 Serial string 127 Extra map[string]string 128} 129 130type VMHandle interface { 131 Shutdown(ctx context.Context) error 132 WaitContext(ctx context.Context) error 133 Close() error 134 Logs() VMLogs 135 CID() uint32 136 WorkDir() string 137 OOMKilled() bool 138} 139 140type VMConfig struct { 141 Image ImageSpec 142 CID uint32 143 EnableKVM bool 144 WorkDir string 145 Cgroup CgroupLimits 146 147 BootTimeout time.Duration 148 MkfsExt4 string 149 Dev bool 150} 151 152type workflowState struct { 153 ImageSpec ImageSpec 154 ImageSpecPath string 155 Config manifestConfig 156 ConfigKey string 157 Image string 158 CacheReadURLs []string 159 CacheTrustedPublicKeys []string 160 VM VMHandle 161 Agent *AgentSession 162 ReadCache *ReadCacheProxy 163 UploadCache *UploadCacheProxy 164 DNSProxy *DNSProxy 165 WorkDir string 166 NixOSToplevelCache nixosToplevelCacheStore 167} 168 169func (e *Engine) cleanupState(ctx context.Context, wid models.WorkflowId, state *workflowState) error { 170 if state == nil { 171 return nil 172 } 173 174 ctx = context.WithoutCancel(ctx) 175 176 var err error 177 // todo(dawn): expose this error to the user as a warning 178 if drainErr := e.drainNixCache(ctx, state); drainErr != nil { 179 e.l.Warn("cache drain failed during cleanup; continuing", "workflow", wid, "error", drainErr) 180 } 181 err = errors.Join(err, e.shutdownVM(ctx, wid, state)) 182 err = errors.Join(err, closeIO(&state.Agent)) 183 err = errors.Join(err, closeIO(&state.ReadCache)) 184 err = errors.Join(err, closeIO(&state.UploadCache)) 185 err = errors.Join(err, closeIO(&state.DNSProxy)) 186 err = errors.Join(err, removeWorkDir(state)) 187 return err 188} 189 190func (e *Engine) drainNixCache(ctx context.Context, state *workflowState) error { 191 if e.cfg.NixCache.UploadURL == "" { 192 return nil 193 } 194 195 drainCtx, cancel := context.WithTimeout(ctx, cacheDrainTimeout) 196 defer cancel() 197 198 if state.Agent != nil { 199 if _, err := state.Agent.Drain(drainCtx); err != nil { 200 return fmt.Errorf("drain guest nix cache uploads: %w", err) 201 } 202 } 203 return nil 204} 205 206func (e *Engine) shutdownVM(ctx context.Context, wid models.WorkflowId, state *workflowState) error { 207 if state.VM == nil { 208 return nil 209 } 210 if vmExited(state.VM) { 211 return closeIO(&state.VM) 212 } 213 214 var poweroffErr error 215 216 if state.Agent != nil { 217 gracefulCtx, cancel := context.WithTimeout(ctx, vmShutdownTimeout) 218 var poweredOff bool 219 poweredOff, poweroffErr = e.poweroffViaAgent(gracefulCtx, wid, state) 220 cancel() 221 222 if poweredOff { 223 return closeIO(&state.VM) 224 } 225 if vmExited(state.VM) { 226 return closeIO(&state.VM) 227 } 228 } 229 230 fallbackCtx, cancel := context.WithTimeout(ctx, vmShutdownTimeout) 231 defer cancel() 232 233 shutdownErr := state.VM.Shutdown(fallbackCtx) 234 if shutdownErr != nil && !vmExited(state.VM) { 235 e.l.Warn("microVM shutdown fallback failed", "workflow", wid, "error", shutdownErr) 236 return errors.Join(poweroffErr, shutdownErr, closeIO(&state.VM)) 237 } 238 239 return closeIO(&state.VM) 240} 241 242func vmExited(vm VMHandle) bool { 243 ctx, cancel := context.WithCancel(context.Background()) 244 cancel() 245 // a cancelled wait means the process is still live 246 // any other result means it exited 247 return !errors.Is(vm.WaitContext(ctx), context.Canceled) 248} 249 250func (e *Engine) poweroffViaAgent(ctx context.Context, wid models.WorkflowId, state *workflowState) (bool, error) { 251 if err := state.Agent.Poweroff(ctx); err != nil { 252 e.l.Warn("agent poweroff request failed", "workflow", wid, "error", err) 253 return false, err 254 } 255 256 if err := state.VM.WaitContext(ctx); err != nil { 257 e.l.Warn("agent poweroff did not stop microVM", "workflow", wid, "error", err) 258 return false, nil 259 } 260 261 return true, nil 262} 263 264// helper for closing io interfaces, sets to nil to prevent double-close 265func closeIO[T io.Closer](field *T) error { 266 closer := *field 267 var zero T 268 *field = zero 269 if any(closer) == any(zero) { 270 return nil 271 } 272 return closer.Close() 273} 274 275func removeWorkDir(state *workflowState) error { 276 if state.WorkDir == "" { 277 return nil 278 } 279 280 err := os.RemoveAll(state.WorkDir) 281 state.WorkDir = "" 282 return err 283} 284 285// returns a context derived from ctx that is cancelled either when ctx itself 286// is cancelled or when the microVM exits on its own. the returned flag reports 287// whether the VM exited (as opposed to ctx being cancelled for another reason, 288// e.g. the workflow timeout), letting callers tell a crash apart from a 289// timeout. cancel must be called to release the watcher goroutine. 290func watchVMExit(ctx context.Context, vm VMHandle) (context.Context, *atomic.Bool, context.CancelFunc) { 291 exited := &atomic.Bool{} 292 watchCtx, cancel := context.WithCancel(ctx) 293 if vm == nil { 294 return watchCtx, exited, cancel 295 } 296 go func() { 297 _ = vm.WaitContext(watchCtx) // returns when VM exits or watchCtx is cancelled 298 if watchCtx.Err() == nil { 299 exited.Store(true) 300 cancel() // don't forget to cancel the watchCtx... 301 } 302 }() 303 return watchCtx, exited, cancel 304} 305 306func VMCrashLog(vm VMHandle) string { 307 if vm == nil { 308 return "" 309 } 310 logs := vm.Logs() 311 312 var b strings.Builder 313 if tail := tailFile(logs.Serial, vmCrashLogTailBytes); tail != "" { 314 fmt.Fprintf(&b, "==== serial log ====\n%s\n", tail) 315 } 316 for _, name := range slices.Sorted(maps.Keys(logs.Extra)) { 317 if tail := tailFile(logs.Extra[name], vmCrashLogTailBytes); tail != "" { 318 fmt.Fprintf(&b, "==== %s log ====\n%s\n", name, tail) 319 } 320 } 321 return strings.TrimRight(b.String(), "\n") 322} 323 324func tailFile(path string, max int64) string { 325 if path == "" { 326 return "" 327 } 328 f, err := os.Open(path) 329 if err != nil { 330 return "" 331 } 332 defer f.Close() 333 if info, err := f.Stat(); err == nil && info.Size() > max { 334 if _, err := f.Seek(-max, io.SeekEnd); err != nil { 335 return "" 336 } 337 } 338 data, err := io.ReadAll(f) 339 if err != nil { 340 return "" 341 } 342 return strings.TrimSpace(string(data)) 343} 344 345func waitAgentConn(ctx context.Context, connCh <-chan net.Conn) (net.Conn, error) { 346 select { 347 case conn := <-connCh: 348 if conn == nil { 349 return nil, fmt.Errorf("agent connection closed before setup") 350 } 351 return conn, nil 352 case <-ctx.Done(): 353 return nil, fmt.Errorf("waiting for agent: %w", ctx.Err()) 354 } 355} 356 357func StartVM(ctx context.Context, cfg VMConfig, logger *slog.Logger) (VMHandle, error) { 358 if logger == nil { 359 logger = slog.Default() 360 } 361 362 runner, err := runnerFor(cfg.Image.RunnerType) 363 if err != nil { 364 return nil, err 365 } 366 if err := cfg.Image.Validate(); err != nil { 367 return nil, err 368 } 369 if err := cfg.Image.validateImageFiles(); err != nil { 370 return nil, err 371 } 372 if err := runner.Validate(cfg.Image, cfg.EnableKVM); err != nil { 373 return nil, err 374 } 375 376 if err := prepareWorkDir(cfg.WorkDir); err != nil { 377 return nil, err 378 } 379 380 mkfsExt4 := cfg.MkfsExt4 381 if mkfsExt4 == "" { 382 mkfsExt4, err = exec.LookPath("mkfs.ext4") 383 if err != nil { 384 return nil, fmt.Errorf("mkfs.ext4 command not found in PATH: %w", err) 385 } 386 } 387 volumePaths, err := prepareVolumes(ctx, cfg.WorkDir, cfg.Image.Volumes, mkfsExt4) 388 if err != nil { 389 return nil, err 390 } 391 392 return runner.Start(ctx, cfg, volumePaths, logger) 393} 394 395// checks serial log for ooms or kernel panic 396// this is very linux specific! but these strings are stable in linux itself, see mm/oom_kill.c and kernel/panic.c 397func ParseCrashLog(detail string) (error, bool) { 398 if strings.Contains(detail, "Out of memory:") { 399 // we can show process name where possible 400 re := regexp.MustCompile(`Out of memory: Killed process \d+ \(([^)]+)\)`) 401 matches := re.FindStringSubmatch(detail) 402 if len(matches) > 1 { 403 return fmt.Errorf("guest out of memory (process '%s' killed by guest kernel OOM)", matches[1]), true 404 } 405 return errors.New("guest out of memory (OOM killer invoked)"), true 406 } 407 if strings.Contains(detail, "Kernel panic") { 408 return errors.New("guest kernel panic"), true 409 } 410 return nil, false 411}