This repository has no description
0

Configure Feed

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

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 211 var err error 212 213 if state.Agent != nil { 214 gracefulCtx, cancel := context.WithTimeout(ctx, vmShutdownTimeout) 215 poweredOff, poweroffErr := e.poweroffViaAgent(gracefulCtx, wid, state) 216 cancel() 217 218 err = errors.Join(err, poweroffErr) 219 if poweredOff { 220 return errors.Join(err, closeIO(&state.VM)) 221 } 222 } 223 224 fallbackCtx, cancel := context.WithTimeout(ctx, vmShutdownTimeout) 225 defer cancel() 226 227 if shutdownErr := state.VM.Shutdown(fallbackCtx); shutdownErr != nil { 228 e.l.Warn("microVM shutdown fallback failed", "workflow", wid, "error", shutdownErr) 229 err = errors.Join(err, shutdownErr) 230 } 231 232 return errors.Join(err, closeIO(&state.VM)) 233} 234 235func (e *Engine) poweroffViaAgent(ctx context.Context, wid models.WorkflowId, state *workflowState) (bool, error) { 236 if err := state.Agent.Poweroff(ctx); err != nil { 237 e.l.Warn("agent poweroff request failed", "workflow", wid, "error", err) 238 return false, err 239 } 240 241 if err := state.VM.WaitContext(ctx); err != nil { 242 e.l.Warn("agent poweroff did not stop microVM", "workflow", wid, "error", err) 243 return false, nil 244 } 245 246 return true, nil 247} 248 249// helper for closing io interfaces, sets to nil to prevent double-close 250func closeIO[T io.Closer](field *T) error { 251 closer := *field 252 var zero T 253 *field = zero 254 if any(closer) == any(zero) { 255 return nil 256 } 257 return closer.Close() 258} 259 260func removeWorkDir(state *workflowState) error { 261 if state.WorkDir == "" { 262 return nil 263 } 264 265 err := os.RemoveAll(state.WorkDir) 266 state.WorkDir = "" 267 return err 268} 269 270// returns a context derived from ctx that is cancelled either when ctx itself 271// is cancelled or when the microVM exits on its own. the returned flag reports 272// whether the VM exited (as opposed to ctx being cancelled for another reason, 273// e.g. the workflow timeout), letting callers tell a crash apart from a 274// timeout. cancel must be called to release the watcher goroutine. 275func watchVMExit(ctx context.Context, vm VMHandle) (context.Context, *atomic.Bool, context.CancelFunc) { 276 exited := &atomic.Bool{} 277 watchCtx, cancel := context.WithCancel(ctx) 278 if vm == nil { 279 return watchCtx, exited, cancel 280 } 281 go func() { 282 _ = vm.WaitContext(watchCtx) // returns when VM exits or watchCtx is cancelled 283 if watchCtx.Err() == nil { 284 exited.Store(true) 285 cancel() // don't forget to cancel the watchCtx... 286 } 287 }() 288 return watchCtx, exited, cancel 289} 290 291func VMCrashLog(vm VMHandle) string { 292 if vm == nil { 293 return "" 294 } 295 logs := vm.Logs() 296 297 var b strings.Builder 298 if tail := tailFile(logs.Serial, vmCrashLogTailBytes); tail != "" { 299 fmt.Fprintf(&b, "==== serial log ====\n%s\n", tail) 300 } 301 for _, name := range slices.Sorted(maps.Keys(logs.Extra)) { 302 if tail := tailFile(logs.Extra[name], vmCrashLogTailBytes); tail != "" { 303 fmt.Fprintf(&b, "==== %s log ====\n%s\n", name, tail) 304 } 305 } 306 return strings.TrimRight(b.String(), "\n") 307} 308 309func tailFile(path string, max int64) string { 310 if path == "" { 311 return "" 312 } 313 f, err := os.Open(path) 314 if err != nil { 315 return "" 316 } 317 defer f.Close() 318 if info, err := f.Stat(); err == nil && info.Size() > max { 319 if _, err := f.Seek(-max, io.SeekEnd); err != nil { 320 return "" 321 } 322 } 323 data, err := io.ReadAll(f) 324 if err != nil { 325 return "" 326 } 327 return strings.TrimSpace(string(data)) 328} 329 330func waitAgentConn(ctx context.Context, connCh <-chan net.Conn) (net.Conn, error) { 331 select { 332 case conn := <-connCh: 333 if conn == nil { 334 return nil, fmt.Errorf("agent connection closed before setup") 335 } 336 return conn, nil 337 case <-ctx.Done(): 338 return nil, fmt.Errorf("waiting for agent: %w", ctx.Err()) 339 } 340} 341 342func StartVM(ctx context.Context, cfg VMConfig, logger *slog.Logger) (VMHandle, error) { 343 if logger == nil { 344 logger = slog.Default() 345 } 346 347 runner, err := runnerFor(cfg.Image.RunnerType) 348 if err != nil { 349 return nil, err 350 } 351 if err := cfg.Image.Validate(); err != nil { 352 return nil, err 353 } 354 if err := cfg.Image.validateImageFiles(); err != nil { 355 return nil, err 356 } 357 if err := runner.Validate(cfg.Image, cfg.EnableKVM); err != nil { 358 return nil, err 359 } 360 361 if err := prepareWorkDir(cfg.WorkDir); err != nil { 362 return nil, err 363 } 364 365 mkfsExt4 := cfg.MkfsExt4 366 if mkfsExt4 == "" { 367 mkfsExt4, err = exec.LookPath("mkfs.ext4") 368 if err != nil { 369 return nil, fmt.Errorf("mkfs.ext4 command not found in PATH: %w", err) 370 } 371 } 372 volumePaths, err := prepareVolumes(ctx, cfg.WorkDir, cfg.Image.Volumes, mkfsExt4) 373 if err != nil { 374 return nil, err 375 } 376 377 return runner.Start(ctx, cfg, volumePaths, logger) 378} 379 380// checks serial log for ooms or kernel panic 381// this is very linux specific! but these strings are stable in linux itself, see mm/oom_kill.c and kernel/panic.c 382func ParseCrashLog(detail string) (error, bool) { 383 if strings.Contains(detail, "Out of memory:") { 384 // we can show process name where possible 385 re := regexp.MustCompile(`Out of memory: Killed process \d+ \(([^)]+)\)`) 386 matches := re.FindStringSubmatch(detail) 387 if len(matches) > 1 { 388 return fmt.Errorf("guest out of memory (process '%s' killed by guest kernel OOM)", matches[1]), true 389 } 390 return errors.New("guest out of memory (OOM killer invoked)"), true 391 } 392 if strings.Contains(detail, "Kernel panic") { 393 return errors.New("guest kernel panic"), true 394 } 395 return nil, false 396}