diff --git a/.gitignore b/.gitignore index ce98d42..3f297f9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,9 @@ ExportOptions.plist # Release pipeline output release-build/ +# Guest build artifacts (kernel Image, compiled agent) — rebuilt via guest/kernel/build.sh + guest/agent/build.sh +guest/out/ + # Dory runtime data .dory/ @@ -65,3 +68,6 @@ cloud/ # Website build output (source lives in website/; built into docs/ via `npm run build`) docs-build/ + +# Go build artifact (build.sh writes the real output to guest/out/) +guest/agent/agent diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 641b323..2f3013a 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -81,7 +81,7 @@ CA trust install remain consent-gated, the same one-time admin grant OrbStack ne | **Bind-mount file sharing** | ✅ | Home dir shared into the VM (virtiofs); verified `docker run -v ~/proj:/app` reads/writes host files live | | One-click Kubernetes | ✅ | `KubernetesProvisioner` runs k3s in the shared VM; verified host `kubectl` + pod deploy; GUI "Enable" button | | Linux machines (Ubuntu/Debian/Fedora/Alpine) | ✅ | `MachineProvider` via `container machine`; verified real machine create/list/start/stop/delete; GUI picker | -| x86/amd64 emulation | ✅ (qemu) | Auto-installs qemu binfmt; verified `--platform linux/amd64 → x86_64`. Rosetta fast-path is a documented gap | +| x86/amd64 emulation | ✅ (qemu, with limits) | Auto-installs qemu binfmt; verified `--platform linux/amd64 → x86_64`. **Heavy amd64 workloads can segfault** under qemu-user on the default `dory-hv` engine — SQL Server (`mcr.microsoft.com/mssql/server`), Oracle, and some AVX/threading-heavy images hit `qemu: uncaught target signal 11`. This is a qemu-user emulation limit, not a Dory bug, and Rosetta cannot run on `dory-hv` (raw Hypervisor.framework). The fast x86 path is the Virtualization.framework helper: `dory vm --arch amd64 --rosetta` / `DORY_ENGINE_ROSETTA=1`. (GitHub #3) | | Volume file browser | ✅ | `VolumeBrowser`; verified list + read files inside volumes; GUI sheet | | Terminal / SSH into containers + machines | ✅ | `TerminalLauncher` opens Terminal.app against Dory's socket/engine | | Docker Desktop / OrbStack migration | ✅ | `MigrationAssistant` imports images + containers into Dory's shared VM; Docker-compatible sources stream image archives directly when possible, so local/private images do not require a registry pull or a full tarball buffered in app memory | @@ -90,11 +90,11 @@ CA trust install remain consent-gated, the same one-time admin grant OrbStack ne ### Apple containerization helper: low-level VM controls delivered -Every feature achievable through Apple's `container` CLI + the dind architecture is done. The four -items below were each investigated and shown to need low-level VM control the CLI does not expose: -device passthrough, memory ballooning, Rosetta device, custom mounts. Dory now delivers those -controls through the bundled `dory-vm` helper, which links the `apple/containerization` Swift -package and drives the VM in-process. +Several features need low-level VM control the `container` CLI does not expose: audio, memory +ballooning, the Rosetta device, custom mounts — all delivered through the bundled `dory-vm` helper, +which links the `apple/containerization` Swift package and drives the VM in-process. USB *device* +passthrough is the exception: the helper attaches a USB controller but does not yet pass a host +device through — real per-device passthrough is the usbip-over-vsock path (roadmap Track 3.6). **Foundation built + PROVEN END-TO-END.** `Packages/ContainerizationEngine/` is an additive Swift package (separate from the shipping app) that links `apple/containerization` and drives the Linux VM @@ -116,10 +116,12 @@ features without linking the framework's large dependency tree. |---|---|---| | Rosetta-speed x86 | ✅ **delivered** | `dory vm --arch amd64 --rosetta -- ` → `uname -m == x86_64`. Verified through the CLI | | Reverse / bidirectional file mount | ✅ **delivered** | `dory vm --mount host:guest -- ` reads/writes host files in the container. Verified | -| USB / audio passthrough | ✅ **delivered** | `dory vm --devices`: a `VZInstanceExtension` injects an XHCI USB controller + `VZVirtioSoundDevice`. Verified `USB controllers attached: 1` | +| Audio passthrough | ✅ **delivered** | `dory vm --devices`: a `VZInstanceExtension` injects `VZVirtioSoundDevice`. Verified audio device configured | +| USB device passthrough | 🚧 **in progress** | `dory vm --devices` attaches a `VZXHCIController`, but **no host USB device is passed through** — it is an empty controller (`USB controllers attached: 1` confirms the controller, not a device). Real per-device passthrough is the usbip-over-vsock path (roadmap Track 3.6), pending the `--usb` hardware gate | | Dynamic memory balloon → macOS | ✅ **delivered** | `dory vm --devices` attaches a balloon and reclaims RAM at runtime via the public `vzVirtualMachine`, verified `1024MiB → 512MiB reclaimed to macOS` | -**All four are delivered** through the bundled, entitlement-signed `dory-vm` helper, surfaced by the +**Rosetta, file mount, audio, and the memory balloon are delivered** through the bundled, +entitlement-signed `dory-vm` helper, surfaced by the `dory` CLI (`dory vm`). The default shared-VM engine is untouched. (A GUI entry point for the in-process engine is not yet wired up.) diff --git a/Dory/App/AppDelegate.swift b/Dory/App/AppDelegate.swift index 327f974..774423b 100644 --- a/Dory/App/AppDelegate.swift +++ b/Dory/App/AppDelegate.swift @@ -1,6 +1,8 @@ import AppKit final class DoryAppDelegate: NSObject, NSApplicationDelegate { + private var wakeObserver: NSObjectProtocol? + static var isTestHost: Bool { ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil || ProcessInfo.processInfo.environment["XCTestSessionIdentifier"] != nil @@ -9,6 +11,13 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { guard !Self.isTestHost else { return } NSApp.setActivationPolicy(.accessory) + wakeObserver = NSWorkspace.shared.notificationCenter.addObserver( + forName: NSWorkspace.didWakeNotification, + object: nil, + queue: .main + ) { _ in + SharedVMProvisioner.resyncClockAfterWake() + } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -16,6 +25,9 @@ final class DoryAppDelegate: NSObject, NSApplicationDelegate { } func applicationWillTerminate(_ notification: Notification) { + if let wakeObserver { + NSWorkspace.shared.notificationCenter.removeObserver(wakeObserver) + } DockerContext.deactivateSync() SharedVMProvisioner.stopEngineDetached() } diff --git a/Dory/Compose/ComposeModel.swift b/Dory/Compose/ComposeModel.swift index 75233f8..376e224 100644 --- a/Dory/Compose/ComposeModel.swift +++ b/Dory/Compose/ComposeModel.swift @@ -329,7 +329,7 @@ enum ComposeParser { return .mapping(merged) } - private static func stripTags(_ value: YAMLValue) -> YAMLValue { + private nonisolated static func stripTags(_ value: YAMLValue) -> YAMLValue { switch value { case let .tagged(_, inner): return stripTags(inner) case let .mapping(map): return .mapping(map.mapValues(stripTags)) diff --git a/Dory/Compose/YAMLParser.swift b/Dory/Compose/YAMLParser.swift index 14b5897..9ff6171 100644 --- a/Dory/Compose/YAMLParser.swift +++ b/Dory/Compose/YAMLParser.swift @@ -12,12 +12,12 @@ struct YAMLParser { private let lines: [Line] private var index = 0 - static func parse(_ text: String) throws -> YAMLValue { + nonisolated static func parse(_ text: String) throws -> YAMLValue { var parser = YAMLParser(text: text) return try parser.parseDocument() } - private init(text: String) { + private nonisolated init(text: String) { var collected: [Line] = [] for raw in text.split(separator: "\n", omittingEmptySubsequences: false) { let stripped = YAMLParser.stripComment(String(raw)) @@ -28,19 +28,19 @@ struct YAMLParser { lines = collected } - private mutating func parseDocument() throws -> YAMLValue { + private nonisolated mutating func parseDocument() throws -> YAMLValue { guard index < lines.count else { return .null } return try parseNode(indent: lines[index].indent) } - private mutating func parseNode(indent: Int) throws -> YAMLValue { + private nonisolated mutating func parseNode(indent: Int) throws -> YAMLValue { if lines[index].content.hasPrefix("- ") || lines[index].content == "-" { return try parseSequence(indent: indent) } return try parseMapping(indent: indent) } - private mutating func parseMapping(indent: Int) throws -> YAMLValue { + private nonisolated mutating func parseMapping(indent: Int) throws -> YAMLValue { var map: [String: YAMLValue] = [:] while index < lines.count, lines[index].indent == indent, !lines[index].content.hasPrefix("- ") { @@ -58,7 +58,7 @@ struct YAMLParser { return .mapping(map) } - private mutating func parseSequence(indent: Int) throws -> YAMLValue { + private nonisolated mutating func parseSequence(indent: Int) throws -> YAMLValue { var seq: [YAMLValue] = [] while index < lines.count, lines[index].indent == indent, lines[index].content.hasPrefix("- ") || lines[index].content == "-" { @@ -92,7 +92,7 @@ struct YAMLParser { return .sequence(seq) } - private mutating func parseChildBlock(parentIndent: Int) throws -> YAMLValue { + private nonisolated mutating func parseChildBlock(parentIndent: Int) throws -> YAMLValue { guard index < lines.count else { return .null } let next = lines[index] if next.indent > parentIndent { @@ -106,7 +106,7 @@ struct YAMLParser { // MARK: Scalars and flow collections - static func scalarOrFlow(_ raw: String) throws -> YAMLValue { + nonisolated static func scalarOrFlow(_ raw: String) throws -> YAMLValue { let trimmed = raw.trimmingCharacters(in: .whitespaces) if let (tag, remainder) = mergeTag(trimmed) { return .tagged(tag, try scalarOrFlow(remainder)) @@ -118,7 +118,7 @@ struct YAMLParser { return scalar(trimmed) } - private static func mergeTag(_ trimmed: String) -> (MergeTag, String)? { + private nonisolated static func mergeTag(_ trimmed: String) -> (MergeTag, String)? { for (token, tag) in [("!override", MergeTag.override), ("!reset", MergeTag.reset)] { if trimmed == token { return (tag, "") } if trimmed.hasPrefix(token + " ") { @@ -128,7 +128,7 @@ struct YAMLParser { return nil } - static func scalar(_ raw: String) -> YAMLValue { + nonisolated static func scalar(_ raw: String) -> YAMLValue { let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("\"") && trimmed.hasSuffix("\"") && trimmed.count >= 2 { return .string(unescape(String(trimmed.dropFirst().dropLast()))) @@ -150,13 +150,13 @@ struct YAMLParser { return .string(trimmed) } - private static func unescape(_ s: String) -> String { + private nonisolated static func unescape(_ s: String) -> String { s.replacingOccurrences(of: "\\n", with: "\n") .replacingOccurrences(of: "\\t", with: "\t") .replacingOccurrences(of: "\\\"", with: "\"") } - static func splitKeyValue(_ content: String) -> (key: String, value: String)? { + nonisolated static func splitKeyValue(_ content: String) -> (key: String, value: String)? { var inSingle = false, inDouble = false let chars = Array(content) var i = 0 @@ -177,13 +177,13 @@ struct YAMLParser { return nil } - private static func unquoteKey(_ key: String) -> String { + private nonisolated static func unquoteKey(_ key: String) -> String { if key.hasPrefix("\"") && key.hasSuffix("\"") && key.count >= 2 { return String(key.dropFirst().dropLast()) } if key.hasPrefix("'") && key.hasSuffix("'") && key.count >= 2 { return String(key.dropFirst().dropLast()) } return key } - private static func stripComment(_ line: String) -> String { + private nonisolated static func stripComment(_ line: String) -> String { var inSingle = false, inDouble = false let chars = Array(line) var i = 0 @@ -205,9 +205,9 @@ struct YAMLParser { private struct FlowScanner { private let chars: [Character] private var i = 0 - init(_ string: String) { chars = Array(string) } + nonisolated init(_ string: String) { chars = Array(string) } - mutating func parseValue() throws -> YAMLValue { + nonisolated mutating func parseValue() throws -> YAMLValue { skipSpaces() guard i < chars.count else { return .null } switch chars[i] { @@ -218,7 +218,7 @@ private struct FlowScanner { } } - private mutating func parseSequence() throws -> YAMLValue { + private nonisolated mutating func parseSequence() throws -> YAMLValue { i += 1 var items: [YAMLValue] = [] skipSpaces() @@ -233,7 +233,7 @@ private struct FlowScanner { return .sequence(items) } - private mutating func parseMapping() throws -> YAMLValue { + private nonisolated mutating func parseMapping() throws -> YAMLValue { i += 1 var map: [String: YAMLValue] = [:] skipSpaces() @@ -253,7 +253,7 @@ private struct FlowScanner { return .mapping(map) } - private mutating func parseQuoted() -> String { + private nonisolated mutating func parseQuoted() -> String { let quote = chars[i]; i += 1 var result = "" while i < chars.count, chars[i] != quote { result.append(chars[i]); i += 1 } @@ -261,18 +261,37 @@ private struct FlowScanner { return result } - private mutating func parseScalar() -> String { + private nonisolated mutating func parseScalar() -> String { var result = "" - while i < chars.count, !",]}".contains(chars[i]) { result.append(chars[i]); i += 1 } + var braceDepth = 0 + while i < chars.count { + let c = chars[i] + if c == "{" { + braceDepth += 1 + result.append(c) + i += 1 + continue + } + if c == "}" { + if braceDepth == 0 { break } + braceDepth -= 1 + result.append(c) + i += 1 + continue + } + if c == "," || c == "]" { break } + result.append(c) + i += 1 + } return result.trimmingCharacters(in: .whitespaces) } - private mutating func parseKey() -> String { + private nonisolated mutating func parseKey() -> String { var result = "" while i < chars.count, !":,]}".contains(chars[i]) { result.append(chars[i]); i += 1 } return result.trimmingCharacters(in: .whitespaces) } - private func peek() -> Character? { i < chars.count ? chars[i] : nil } - private mutating func skipSpaces() { while i < chars.count, chars[i] == " " { i += 1 } } + private nonisolated func peek() -> Character? { i < chars.count ? chars[i] : nil } + private nonisolated mutating func skipSpaces() { while i < chars.count, chars[i] == " " { i += 1 } } } diff --git a/Dory/Features/Containers/ContainersView.swift b/Dory/Features/Containers/ContainersView.swift index 2786bc5..4d71f45 100644 --- a/Dory/Features/Containers/ContainersView.swift +++ b/Dory/Features/Containers/ContainersView.swift @@ -35,21 +35,11 @@ struct ContainersView: View { SkeletonRows() Spacer(minLength: 0) } else if store.loadState == .engineOff { - if store.needsContainerToolchain || store.toolchainInstallPhase != .idle { - VStack { - Spacer(minLength: 0) - EngineSetupCard() - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(40) - } else { - TableEmptyState(glyph: .containers, title: "Engine not running", - message: store.sharedVMStatus.isEmpty - ? "Dory's container engine isn't running yet. It starts automatically when Dory connects." - : store.sharedVMStatus, - actionLabel: "Try again", action: { Task { await store.retryEngine() } }) - } + TableEmptyState(glyph: .containers, title: "Engine not running", + message: store.sharedVMStatus.isEmpty + ? "Dory's engine isn't running yet. It starts automatically when Dory connects." + : store.sharedVMStatus, + actionLabel: "Try again", action: { Task { await store.retryEngine() } }) } else if store.containers.isEmpty { TableEmptyState(glyph: .containers, title: "No containers yet", message: "Run a container from an image, or start one with `docker run`.", diff --git a/Dory/Features/EngineSetupView.swift b/Dory/Features/EngineSetupView.swift deleted file mode 100644 index 2107883..0000000 --- a/Dory/Features/EngineSetupView.swift +++ /dev/null @@ -1,105 +0,0 @@ -import SwiftUI -import AppKit - -/// Guided first-run setup shown when the engine can't start because Apple's container -/// toolchain isn't installed. Offers a one-click Homebrew install when brew is present, -/// or the copyable command / installer download plus a re-check for manual installs. -struct EngineSetupCard: View { - @Environment(AppStore.self) private var store - @Environment(\.palette) private var p - @State private var copied = false - - var body: some View { - VStack(spacing: 0) { - Text("One-time setup").font(.system(size: 19, weight: .heavy)).foregroundStyle(p.text) - .padding(.bottom, 6) - Text("Dory runs containers with Apple's open-source container engine, which isn't on this Mac yet. Install it once — Dory handles everything after that.") - .font(.system(size: 13)).foregroundStyle(p.text2) - .multilineTextAlignment(.center).lineSpacing(3) - .frame(maxWidth: 400) - .padding(.bottom, 18) - - commandRow(AppStore.toolchainInstallCommand) - .frame(maxWidth: 400) - .padding(.bottom, 16) - - if case .failed(let message) = store.toolchainInstallPhase { - Text(message) - .font(.system(size: 12)).foregroundStyle(p.red) - .multilineTextAlignment(.center) - .frame(maxWidth: 400) - .padding(.bottom, 12) - } - - if store.canAutoInstallToolchain { - primaryButton(primaryLabel, busy: store.toolchainInstallPhase.isBusy, id: "engine-setup-install") { - Task { await store.installContainerToolchain() } - } - } else { - primaryButton("Download the installer ↗", busy: false, id: "engine-setup-download") { - guard let url = URL(string: AppStore.toolchainReleasesURL) else { return } - NSWorkspace.shared.open(url) - } - } - - Button { - Task { await store.recheckToolchain() } - } label: { - Text("I installed it myself — check again") - .font(.system(size: 12.5, weight: .medium)).foregroundStyle(p.text3) - } - .buttonStyle(.plain) - .disabled(store.toolchainInstallPhase.isBusy) - .accessibilityIdentifier("engine-setup-recheck") - .padding(.top, 10) - } - } - - private var primaryLabel: String { - switch store.toolchainInstallPhase { - case .installing: "Installing toolchain… (about a minute)" - case .startingEngine: "Starting the engine…" - case .idle, .failed: "Install & start engine" - } - } - - private func primaryButton(_ title: String, busy: Bool, id: String, action: @escaping () -> Void) -> some View { - Button(action: action) { - HStack(spacing: 8) { - if busy { ProgressView().controlSize(.small).tint(.white) } - Text(title).font(.system(size: 13, weight: .semibold)).foregroundStyle(.white) - } - .padding(.horizontal, 18).padding(.vertical, 9) - .background(p.accent, in: RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(.plain) - .disabled(busy) - .accessibilityIdentifier(id) - } - - private func commandRow(_ command: String) -> some View { - HStack(spacing: 10) { - Text(command).font(.mono(12.5, weight: .regular)).foregroundStyle(p.monoText) - .lineLimit(1).truncationMode(.tail) - Spacer(minLength: 0) - Button { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(command, forType: .string) - copied = true - Task { - try? await Task.sleep(for: .seconds(1.5)) - copied = false - } - } label: { - if copied { - Text("Copied").font(.system(size: 11, weight: .semibold)).foregroundStyle(p.green) - } else { - Glyph(glyph: .plus, size: 13, color: p.text3) - } - } - .buttonStyle(.plain).help("Copy") - } - .padding(.horizontal, 14).padding(.vertical, 11) - .background(p.monoBg, in: RoundedRectangle(cornerRadius: 9)) - } -} diff --git a/Dory/Features/Onboarding/OnboardingView.swift b/Dory/Features/Onboarding/OnboardingView.swift index 6e15af7..b0e3c13 100644 --- a/Dory/Features/Onboarding/OnboardingView.swift +++ b/Dory/Features/Onboarding/OnboardingView.swift @@ -69,24 +69,20 @@ struct OnboardingView: View { private var startingStep: some View { VStack(spacing: 0) { - if store.needsContainerToolchain || store.toolchainInstallPhase != .idle { - EngineSetupCard() - } else { - Text("Starting the Dory engine").font(.system(size: 19, weight: .heavy)).foregroundStyle(p.text) - .padding(.bottom, 6) - Text(store.sharedVMStatus.isEmpty ? "Provisioning your shared VM…" : store.sharedVMStatus) - .font(.system(size: 13)).foregroundStyle(p.text2).multilineTextAlignment(.center) - .padding(.bottom, 22) + Text("Starting the Dory engine").font(.system(size: 19, weight: .heavy)).foregroundStyle(p.text) + .padding(.bottom, 6) + Text(store.sharedVMStatus.isEmpty ? "Provisioning your engine…" : store.sharedVMStatus) + .font(.system(size: 13)).foregroundStyle(p.text2).multilineTextAlignment(.center) + .padding(.bottom, 22) - ProgressView().controlSize(.large).padding(.bottom, 22) + ProgressView().controlSize(.large).padding(.bottom, 22) - Text("First launch fetches the engine once — this is the only wait.") - .font(.system(size: 11.5)).foregroundStyle(p.text3).multilineTextAlignment(.center) - .padding(.bottom, 18) + Text("First launch fetches the engine once — this is the only wait.") + .font(.system(size: 11.5)).foregroundStyle(p.text3).multilineTextAlignment(.center) + .padding(.bottom, 18) - if waited { - primaryButton("Continue", id: "onboarding-continue") { step = .demo } - } + if waited { + primaryButton("Continue", id: "onboarding-continue") { step = .demo } } skipButton.padding(.top, 9) } diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift index c1f20d3..e9ecfdd 100644 --- a/Dory/Features/Settings/SettingsView.swift +++ b/Dory/Features/Settings/SettingsView.swift @@ -4,6 +4,9 @@ struct SettingsView: View { @Environment(AppStore.self) private var store @Environment(\.palette) private var p @State private var envAllowListDraft = "" + @State private var dnsPortDraft = "" + @State private var httpPortDraft = "" + @State private var httpsPortDraft = "" var body: some View { HStack(spacing: 0) { @@ -44,7 +47,8 @@ struct SettingsView: View { case .general: general case .resources: resources case .engine: engine - case .network: infoPanel(networkText) + case .network: network + case .usb: UsbDevicesView() case .migrate: migrate case .about: infoPanel(aboutText) } @@ -57,10 +61,27 @@ struct SettingsView: View { Text("Import your images and containers from Docker Desktop, OrbStack, Colima, Rancher Desktop, Podman, or another Docker-compatible engine onto Dory's engine. Your source engine is only read — nothing there is modified, so you can switch back anytime.") .font(.system(size: 12.5)).foregroundStyle(p.text2).lineSpacing(4) .frame(maxWidth: .infinity, alignment: .leading) + if store.migrationSources.count > 1 { + HStack(spacing: 8) { + Text("Source").font(.system(size: 11.5)).foregroundStyle(p.text3) + Picker("Source", selection: Binding( + get: { store.selectedMigrationSourcePath ?? store.migrationSources.first?.socketPath ?? "" }, + set: { path in Task { await store.selectMigrationSource(path) } } + )) { + ForEach(store.migrationSources) { engine in + Text(engine.label).tag(engine.socketPath) + } + } + .labelsHidden().fixedSize() + .accessibilityIdentifier("migrate-source-picker") + } + } if let inv = store.migrationInventory { preflightPanel(inv) } else { - Text("No Docker-compatible local engine detected.") + Text(store.migrationSources.isEmpty + ? "No Docker-compatible local engine detected." + : "Couldn't read \(store.migrationSources.first(where: { $0.socketPath == store.selectedMigrationSourcePath })?.label ?? "the selected engine") — is it running?") .font(.system(size: 11.5)).foregroundStyle(p.text3) } Button { @@ -84,6 +105,17 @@ struct SettingsView: View { if !store.migrationStatus.isEmpty { Text(store.migrationStatus).font(.system(size: 11.5)).foregroundStyle(p.text3) } + if let failures = store.migrationSummary?.failures, !failures.isEmpty { + VStack(alignment: .leading, spacing: 3) { + ForEach(failures.prefix(8), id: \.self) { failure in + Text("• \(failure)").font(.system(size: 11)).foregroundStyle(p.red) + .frame(maxWidth: .infinity, alignment: .leading) + } + if failures.count > 8 { + Text("+ \(failures.count - 8) more").font(.system(size: 11)).foregroundStyle(p.text3) + } + } + } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) @@ -378,13 +410,13 @@ struct SettingsView: View { groupLabel("DORY SHARED VM") VStack(alignment: .leading, spacing: 12) { - Text("Run every container in one shared Linux VM — like OrbStack — instead of a VM per container. Lower memory for multi-container stacks, and Dory becomes a standalone engine that no longer needs Docker or OrbStack. Requires macOS 26 or later on Apple silicon; older Macs can use a Docker-compatible local engine.") + Text("Run every container in one shared Linux VM — like OrbStack — on Dory's own engine. A standalone daemon that ships its own kernel and networking, so it needs no Docker, OrbStack, or Apple container toolchain, and reclaims memory back to macOS as workloads idle. Requires macOS 15 or later on Apple silicon; older Macs can use a Docker-compatible local engine.") .font(.system(size: 12.5)).foregroundStyle(p.text2).lineSpacing(4) .frame(maxWidth: .infinity, alignment: .leading) Button { Task { await store.useSharedVM() } } label: { - Text(onShared ? "Running on Dory's shared VM" : "Use Dory's shared VM") + Text(onShared ? "Running on Dory's engine" : "Use Dory's engine") .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(onShared ? p.text2 : .white) .padding(.horizontal, 16).padding(.vertical, 9) @@ -404,13 +436,31 @@ struct SettingsView: View { .frame(maxWidth: .infinity, alignment: .leading) .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + .padding(.bottom, 22) + + groupLabel("X86 / AMD64") + VStack(spacing: 0) { + toggleRow( + "Run x86 images with Rosetta", + "Use Apple's Rosetta to run amd64 images (SQL Server, Oracle, older x86 builds) reliably and fast. Switches Dory's engine to Virtualization.framework, which uses more memory than the default engine — turn off when you don't need x86.", + isOn: Binding(get: { store.rosettaX86Enabled }, set: { on in Task { await store.setRosettaX86(on) } }), + divider: false, + disabled: !onShared + ) + } + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + if !onShared { + Text("Switch to Dory's shared engine above to use Rosetta x86.") + .font(.system(size: 11.5)).foregroundStyle(p.text3).padding(.top, 8) + } } } private func engineDescription(for kind: RuntimeKind) -> String { switch kind { case .docker: "Proxying a host Docker-compatible engine" - case .sharedVM: "One shared Linux VM on Apple's container engine" + case .sharedVM: "One shared Linux VM on Dory's own engine" case .appleContainer: "Apple container — one micro-VM per container" case .mock: "Demo data" } @@ -430,7 +480,64 @@ struct SettingsView: View { .padding(.bottom, 10) } - private let networkText = "All containers receive an automatic *.dory.local domain backed by the built-in DNS resolver. HTTPS certificates are issued locally and trusted system-wide. Default bridge subnet 192.168.215.0/24." + private var network: some View { + VStack(alignment: .leading, spacing: 0) { + groupLabel("LOCAL DOMAINS") + VStack(spacing: 0) { + toggleRow( + "Enable *.dory.local domains", + "Give each container an automatic *.dory.local name with local HTTPS. Turn this off if the proxy ports conflict, or if your DNS is managed (MDM / corporate) and can't be pointed at Dory.", + isOn: Binding(get: { store.domainsEnabled }, set: { store.applyNetworkingSettings(domainsEnabled: $0) }), + divider: false + ) + } + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + .padding(.bottom, 22) + + groupLabel("PORTS") + VStack(alignment: .leading, spacing: 12) { + portField("DNS resolver", store: $dnsPortDraft, fallback: AppStore.defaultDNSPort) { store.applyNetworkingSettings(dnsPort: $0) } + portField("HTTP proxy", store: $httpPortDraft, fallback: AppStore.defaultHTTPProxyPort) { store.applyNetworkingSettings(httpProxyPort: $0) } + portField("HTTPS proxy", store: $httpsPortDraft, fallback: AppStore.defaultHTTPSProxyPort) { store.applyNetworkingSettings(httpsProxyPort: $0) } + Text("Change these if the defaults (15353 / 8080 / 8443) collide with other software — 8080 is a common one. Saved on Return; local networking restarts to rebind.") + .font(.system(size: 11.5)).foregroundStyle(p.text3).lineSpacing(3) + } + .padding(15) + .frame(maxWidth: .infinity, alignment: .leading) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + .opacity(store.domainsEnabled ? 1 : 0.55) + .allowsHitTesting(store.domainsEnabled) + + Text("Published container ports stay reachable at localhost regardless of this setting. Default bridge subnet 192.168.215.0/24.") + .font(.system(size: 11.5)).foregroundStyle(p.text3).lineSpacing(3) + .padding(.top, 14) + } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + .onAppear { + dnsPortDraft = String(store.dnsPort) + httpPortDraft = String(store.httpProxyPort) + httpsPortDraft = String(store.httpsProxyPort) + } + } + + private func portField(_ label: String, store draft: Binding, fallback: UInt16, apply: @escaping (UInt16) -> Void) -> some View { + HStack(spacing: 12) { + Text(label).font(.system(size: 12.5)).foregroundStyle(p.text2).frame(width: 110, alignment: .leading) + TextField(String(fallback), text: draft, onCommit: { + let port = UInt16(draft.wrappedValue.trimmingCharacters(in: .whitespaces)) ?? fallback + draft.wrappedValue = String(port) + apply(port) + }) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(width: 100) + .accessibilityIdentifier("port-\(label)") + Spacer(minLength: 0) + } + } private var aboutText: String { "Dory \(AppInfo.version) (build \(AppInfo.build)). A lighter, memory-efficient alternative to Docker Desktop and OrbStack, built for macOS — free and open source. © 2026 Dory contributors." } diff --git a/Dory/Features/Settings/UsbDevicesView.swift b/Dory/Features/Settings/UsbDevicesView.swift new file mode 100644 index 0000000..8b0fd8b --- /dev/null +++ b/Dory/Features/Settings/UsbDevicesView.swift @@ -0,0 +1,245 @@ +import SwiftUI + +struct UsbDevicesView: View { + @Environment(\.palette) private var p + @State private var devicesOutput = "" + @State private var machine = UserDefaults.standard.string(forKey: "dev.dory.usb.lastMachine") ?? "default" + @State private var busid = "" + @State private var port = "" + @State private var rememberAttachment = true + @State private var remembered: [UsbAttachment] = UsbAttachmentStore().attachments() + @State private var busy = false + @State private var status = "" + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + groupLabel("HOST USB") + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Button { Task { await refresh() } } label: { + Label("Refresh", systemImage: "arrow.clockwise") + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.bordered) + .disabled(busy) + Spacer(minLength: 0) + if busy { ProgressView().controlSize(.small) } + } + + ScrollView { + Text(devicesOutput.isEmpty ? "No USB scan has run yet." : devicesOutput) + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(devicesOutput.isEmpty ? p.text3 : p.text2) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + } + .frame(minHeight: 180, maxHeight: 280) + .background(p.bgInput, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(p.border)) + } + .padding(16) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + + groupLabel("ATTACHMENT") + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + TextField("machine", text: $machine) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .accessibilityIdentifier("usb-machine") + TextField("bus id or vid:pid", text: $busid) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .accessibilityIdentifier("usb-busid") + TextField("port", text: $port) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(width: 76) + .accessibilityIdentifier("usb-port") + } + + Toggle("Remember for this machine", isOn: $rememberAttachment) + .font(.system(size: 12.5)) + .toggleStyle(.checkbox) + + if rememberAttachment && validRememberPort() == nil { + Text("Enter a valid port (1-65535) to remember this attachment for automatic replay.") + .font(.system(size: 11)) + .foregroundStyle(p.text3) + } + + HStack(spacing: 10) { + Button { Task { await attach() } } label: { + Label("Attach", systemImage: "cable.connector") + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.borderedProminent) + .disabled(busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button { Task { await detach() } } label: { + Label("Detach", systemImage: "xmark.circle") + .font(.system(size: 12.5, weight: .semibold)) + } + .buttonStyle(.bordered) + .disabled(busy || busid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if !status.isEmpty { + Text(status) + .font(.system(size: 11.5)) + .foregroundStyle(p.text3) + .textSelection(.enabled) + } + + if !remembered.isEmpty { + Divider() + VStack(alignment: .leading, spacing: 8) { + ForEach(remembered) { attachment in + HStack(spacing: 8) { + Text("\(attachment.machine) \(attachment.busID) port \(attachment.port)") + .font(.system(size: 11.5, design: .monospaced)) + .foregroundStyle(p.text2) + .lineLimit(1) + Spacer(minLength: 0) + Button { + forget(attachment) + } label: { + Image(systemName: "trash") + } + .buttonStyle(.borderless) + .help("Forget attachment") + } + } + } + } + } + .padding(16) + .background(p.bgElevated, in: RoundedRectangle(cornerRadius: 11)) + .overlay(RoundedRectangle(cornerRadius: 11).strokeBorder(p.border)) + } + .task { + if devicesOutput.isEmpty { await refresh() } + } + } + + private func groupLabel(_ text: String) -> some View { + Text(text).font(.system(size: 11, weight: .bold)).tracking(0.5).foregroundStyle(p.text3) + .padding(.bottom, -10) + } + + @MainActor private func refresh() async { + busy = true + defer { busy = false } + let result = await Self.runDory(["usb", "ls"]) + devicesOutput = result.output + status = result.succeeded ? "USB devices refreshed." : "USB scan failed: \(result.output)" + } + + @MainActor private func attach() async { + busy = true + defer { busy = false } + let args = usbCommand("attach") + let result = await Self.runDory(args) + if result.succeeded { + rememberIfNeeded() + status = "Attached \(cleanBusID())." + } else { + status = "Attach failed: \(result.output)" + } + } + + @MainActor private func detach() async { + busy = true + defer { busy = false } + let args = usbCommand("detach") + let result = await Self.runDory(args) + if result.succeeded { + try? UsbAttachmentStore().forget(machine: cleanMachine(), busID: cleanBusID()) + reloadRemembered() + status = "Detached \(cleanBusID())." + } else { + status = "Detach failed: \(result.output)" + } + } + + @MainActor private func rememberIfNeeded() { + guard rememberAttachment else { return } + guard let port = validRememberPort() else { + status = "Attached \(cleanBusID()), but not remembered: enter a valid port (1-65535) to replay it automatically." + return + } + do { + UserDefaults.standard.set(cleanMachine(), forKey: "dev.dory.usb.lastMachine") + _ = try UsbAttachmentStore().remember(machine: cleanMachine(), busID: cleanBusID(), port: port) + reloadRemembered() + } catch { + status = "Attached, but could not remember it: \(error)" + } + } + + private func validRememberPort() -> Int? { + guard let port = Int(cleanPort()), (1...65_535).contains(port) else { return nil } + return port + } + + @MainActor private func forget(_ attachment: UsbAttachment) { + try? UsbAttachmentStore().forget(machine: attachment.machine, busID: attachment.busID) + reloadRemembered() + } + + @MainActor private func reloadRemembered() { + remembered = UsbAttachmentStore().attachments() + } + + private func cleanMachine() -> String { machine.trimmingCharacters(in: .whitespacesAndNewlines) } + private func cleanBusID() -> String { busid.trimmingCharacters(in: .whitespacesAndNewlines) } + private func cleanPort() -> String { port.trimmingCharacters(in: .whitespacesAndNewlines) } + + private func usbCommand(_ action: String) -> [String] { + var args = ["usb", action, cleanBusID()] + if !cleanPort().isEmpty { + args += ["--port", cleanPort()] + } + if !cleanMachine().isEmpty { + args += ["--machine", cleanMachine()] + } + return args + } + + nonisolated static func runDory(_ arguments: [String]) async -> CommandResult { + await Task.detached(priority: .userInitiated) { + let process = Process() + process.executableURL = doryCLIURL() + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + process.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return CommandResult(succeeded: process.terminationStatus == 0, output: output) + } catch { + return CommandResult(succeeded: false, output: error.localizedDescription) + } + }.value + } + + nonisolated private static func doryCLIURL() -> URL { + if let override = ProcessInfo.processInfo.environment["DORY_CLI"], !override.isEmpty { + return URL(fileURLWithPath: override) + } + if FileManager.default.isExecutableFile(atPath: "/usr/local/bin/dory") { + return URL(fileURLWithPath: "/usr/local/bin/dory") + } + return URL(fileURLWithPath: "/opt/homebrew/bin/dory") + } + + struct CommandResult: Sendable, Equatable { + let succeeded: Bool + let output: String + } +} diff --git a/Dory/Features/Sheets/NewMachineSheet.swift b/Dory/Features/Sheets/NewMachineSheet.swift index c99a860..ac7ede5 100644 --- a/Dory/Features/Sheets/NewMachineSheet.swift +++ b/Dory/Features/Sheets/NewMachineSheet.swift @@ -369,7 +369,7 @@ struct NewMachineSheet: View { .frame(width: 240, alignment: .leading) .disabled(selectedFamily.arches.count < 2) if !selectedArch.isNative { - Text("Emulated via binfmt — slower than \(MachineArch.host.display). Fine for builds and testing.") + Text("Emulated via binfmt, slower than \(MachineArch.host.display). Fine for builds and testing. For near-native x86, run one-off commands with `dory vm --arch amd64 --rosetta`.") .font(.system(size: 11)).foregroundStyle(p.text3) .frame(width: 240, alignment: .leading) } diff --git a/Dory/Features/Tables/KubernetesView.swift b/Dory/Features/Tables/KubernetesView.swift index d101476..75adc76 100644 --- a/Dory/Features/Tables/KubernetesView.swift +++ b/Dory/Features/Tables/KubernetesView.swift @@ -23,7 +23,7 @@ struct KubernetesView: View { .overlay(RoundedRectangle(cornerRadius: 16).strokeBorder(p.border)) Text("Kubernetes is not running").font(.system(size: 15, weight: .semibold)).foregroundStyle(p.text) Text(store.kubernetesBusy ? store.kubernetesInfo : (store.runtimeKind == .sharedVM - ? "Run a one-click local k3s cluster inside Dory's shared VM.\nBuilt images are usable in Pods immediately — no registry push." + ? "Run a one-click local k3s cluster inside Dory's shared VM.\nk3s has its own image store — push a built image to a registry or import it into the cluster to use it in Pods." : "Kubernetes runs inside Dory's shared VM. Switch to it in Settings → Docker Engine to enable Kubernetes.")) .font(.system(size: 12.5)).foregroundStyle(p.text3).multilineTextAlignment(.center).lineSpacing(3) Picker("", selection: Binding( diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index b812532..1d1801d 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -6,15 +6,6 @@ import UniformTypeIdentifiers enum LoadState: Sendable { case connecting, ready, engineOff } -enum ToolchainInstallPhase: Equatable, Sendable { - case idle - case installing - case startingEngine - case failed(String) - - var isBusy: Bool { self == .installing || self == .startingEngine } -} - enum ContainerFilter: String, CaseIterable, Sendable { case running, all, stopped var label: String { @@ -134,6 +125,11 @@ final class AppStore { if let saved = UserDefaults.standard.string(forKey: Self.kubernetesVersionKey) { kubernetesVersionTag = KubeVersionCatalog.version(forTag: saved).tag } + if let v = UserDefaults.standard.object(forKey: Self.dnsPortKey) as? Int, let p = UInt16(exactly: v), p > 0 { dnsPort = p } + if let v = UserDefaults.standard.object(forKey: Self.httpProxyPortKey) as? Int, let p = UInt16(exactly: v), p > 0 { httpProxyPort = p } + if let v = UserDefaults.standard.object(forKey: Self.httpsProxyPortKey) as? Int, let p = UInt16(exactly: v), p > 0 { httpsProxyPort = p } + if let v = UserDefaults.standard.object(forKey: Self.domainsEnabledKey) as? Bool { domainsEnabled = v } + if let v = UserDefaults.standard.object(forKey: SharedVMProvisioner.Config.rosettaX86Key) as? Bool { rosettaX86Enabled = v } dockerHostCleaned = DockerHostConflict.hasCleaned dockerHostConflictDismissed = UserDefaults.standard.bool(forKey: Self.dockerHostDismissedKey) if let width = UserDefaults.standard.object(forKey: Self.containerDetailWidthKey) as? Double, width >= 320 { @@ -313,6 +309,9 @@ final class AppStore { private var shimServer: ShimHTTPServer? var shimSocketPath: String { DockerShim.defaultSocketPath } private(set) var shimRunning = false + /// Opt-in: run the Virtualization.framework engine with Rosetta so heavy amd64 images (SQL Server) + /// run reliably. Trades away dory-hv's memory advantage while on, so it is a manual toggle (#3). + var rosettaX86Enabled = false @ObservationIgnored private(set) var backendStartRequested = false @ObservationIgnored var windowOpenRequested = false @@ -330,10 +329,7 @@ final class AppStore { switch ProcessInfo.processInfo.environment["DORY_RUNTIME"] { case "mock": await reload() - case "apple": - if let apple = await AppleContainerRuntime.detect() { runtime = apple; await reload() } - else { loadState = .engineOff } - case "docker": + case "docker", "docker-proxy": if let docker = await DockerEngineRuntime.detect() { runtime = docker; await reload() } else { loadState = .engineOff } case "shared": @@ -345,34 +341,30 @@ final class AppStore { } if let shared = await SharedVMProvisioner.runtime() { runtime = shared - sharedVMStatus = "Running on Dory's shared VM" + sharedVMStatus = "Running on Dory's engine" await reload() } else { - sharedVMStatus = "Shared VM unavailable (could not start Apple's container engine)" + sharedVMStatus = "Dory's engine could not start — see ~/.dory/engine.log" loadState = .engineOff } - case "docker-proxy": - if let docker = await DockerEngineRuntime.detect() { runtime = docker; await reload() } - else { loadState = .engineOff } default: - // Dory's own shared VM is the default engine — a standalone, OrbStack-style daemon. - // Fall back to fronting an existing Docker-compatible socket, then Apple per-container. + // Dory's own engine (dory-hv) is the default: a standalone, OrbStack-style daemon that + // ships everything it needs. On hardware it can't run (Intel / older macOS) fall back to + // fronting an existing Docker-compatible socket. let support = refreshSharedVMSupport() if support.isSupported { sharedVMStatus = "Starting Dory's engine…" if let shared = await SharedVMProvisioner.runtime() { - runtime = shared; sharedVMStatus = "Running on Dory's shared VM"; await reload() + runtime = shared; sharedVMStatus = "Running on Dory's engine"; await reload() break } - sharedVMStatus = "Shared VM unavailable (could not start Apple's container engine)" + sharedVMStatus = "Dory's engine could not start — see ~/.dory/engine.log" } else { sharedVMStatus = Self.sharedVMUnavailableStatus(support) } if let docker = await DockerEngineRuntime.detect() { runtime = docker; await reload() - } else if let apple = await AppleContainerRuntime.detect() { - runtime = apple; await reload() } else { loadState = .engineOff } @@ -397,7 +389,6 @@ final class AppStore { var sharedVMStatus = "" var sharedVMSupport = SharedVMProvisioner.hostSupport() - var toolchainInstallPhase: ToolchainInstallPhase = .idle private func refreshSharedVMSupport() -> RuntimeSupport { let support = SharedVMProvisioner.hostSupport() @@ -405,70 +396,22 @@ final class AppStore { return support } - nonisolated static let toolchainInstallCommand = "brew install container" - nonisolated static let toolchainReleasesURL = "https://github.com/apple/container/releases/latest" - - /// True when the engine is down solely because Apple's container toolchain isn't installed — - /// the one engine-off cause a user can fix in place, so the UI offers a guided install. - var needsContainerToolchain: Bool { - loadState == .engineOff && sharedVMSupport.issue == .missingToolchain - } - - var canAutoInstallToolchain: Bool { Self.brewBinary() != nil } - - nonisolated static func brewBinary() -> String? { - Shell.find("brew", candidates: ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]) - } - - func installContainerToolchain() async { - guard !toolchainInstallPhase.isBusy, !isConnecting else { return } - guard let brew = Self.brewBinary() else { - toolchainInstallPhase = .failed("Homebrew isn't installed — use the installer download instead, then check again.") - return - } - toolchainInstallPhase = .installing - let install = await Shell.runAsyncResult(brew, ["install", "container"]) - guard install.exit == 0 else { - toolchainInstallPhase = .failed(Self.toolchainFailureSummary( - install.output, fallback: "Homebrew could not install the container toolchain.")) - return - } - await completeToolchainSetup() - } - - /// Re-check after a manual install (Homebrew in a terminal, or Apple's pkg installer). - func recheckToolchain() async { - guard !toolchainInstallPhase.isBusy, !isConnecting else { return } - guard SharedVMProvisioner.containerBinary() != nil else { - toolchainInstallPhase = .failed("Apple's container toolchain still isn't installed.") - return - } - await completeToolchainSetup() - } - func retryEngine() async { guard !isConnecting else { return } await connectBackend() } - private func completeToolchainSetup() async { - toolchainInstallPhase = .startingEngine - await retryEngine() - if loadState == .engineOff { - let support = sharedVMSupport - toolchainInstallPhase = .failed(support.isSupported - ? "The engine didn't start. Run `container system status` in a terminal, then try again." - : Self.sharedVMUnavailableStatus(support)) - } else { - toolchainInstallPhase = .idle - } - } - - nonisolated private static func toolchainFailureSummary(_ output: String, fallback: String) -> String { - let lines = output.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } - let summary = lines.last(where: { $0.localizedCaseInsensitiveContains("error") }) ?? lines.last - guard let summary, !summary.isEmpty else { return fallback } - return String(summary.prefix(220)) + /// Toggles the opt-in Rosetta x86 engine and restarts the shared engine so the new mode takes + /// effect. On → Virtualization.framework + Rosetta (heavy amd64 like SQL Server works, more + /// memory). Off → dory-hv (the memory advantage). No-op unless the shared engine is active. + func setRosettaX86(_ on: Bool) async { + guard on != rosettaX86Enabled else { return } + rosettaX86Enabled = on + UserDefaults.standard.set(on, forKey: SharedVMProvisioner.Config.rosettaX86Key) + guard runtimeKind == .sharedVM, !isConnecting else { return } + sharedVMStatus = on ? "Switching to the Rosetta x86 engine…" : "Switching to Dory's engine…" + SharedVMProvisioner.stopEngineDetached() + await connectBackend() } /// Provisions (or reuses) Dory's own single shared Linux VM and switches the live engine to it, @@ -480,9 +423,9 @@ final class AppStore { sharedVMStatus = Self.sharedVMUnavailableStatus(support) return } - sharedVMStatus = "Starting Dory's shared VM…" + sharedVMStatus = "Starting Dory's engine…" guard let shared = await SharedVMProvisioner.runtime() else { - sharedVMStatus = "Shared VM unavailable (could not start Apple's container engine)" + sharedVMStatus = "Dory's engine could not start — see ~/.dory/engine.log" return } runtime = shared @@ -501,17 +444,18 @@ final class AppStore { } let domainSuffix = "dory.local" - private let portForwarder = HostPortForwarder( - targetHost: "127.0.0.1", - containerBinary: SharedVMProvisioner.containerBinary(), - engineName: SharedVMProvisioner.engineName - ) + private let portForwarder = HostPortForwarder(targetHost: "127.0.0.1") @ObservationIgnored private lazy var hostBridge = HostBridgeWatcher( bridgeRoot: URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".dory/bridge"), forwarder: portForwarder, enabled: openLoginsOnMac, open: { url in DispatchQueue.main.async { NSWorkspace.shared.open(url) } } ) + @ObservationIgnored private lazy var credentialProxy = CredentialProxyManager( + bridgeRoot: URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".dory/bridge") + ) + @ObservationIgnored private let usbAttachments = UsbAttachmentStore() + @ObservationIgnored private var usbReplayedMachines: Set = [] private let domainTable = DomainTable() private let dns = DoryDNS() @ObservationIgnored private let reverseProxy: DoryReverseProxy @@ -531,35 +475,49 @@ final class AppStore { let forwarder = self.portForwarder let table = self.domainTable let suffix = self.domainSuffix + // Dory's engine publishes container ports to the host through gvproxy, so published ports + // are already reachable at 127.0.0.1. The app never binds them itself (that would race + // gvproxy); it only points *.dory.local domains at those localhost ports. + forwarder.updateTarget("127.0.0.1") portForwardingTask = Task { [weak self] in while !Task.isCancelled { - if let ip = await SharedVMProvisioner.engineIP(), ip != "127.0.0.1" { - forwarder.updateTarget(ip) - let endpoints = await Self.containerEndpoints(runtime, suffix: suffix) - forwarder.sync(ports: await Self.allPublishedPorts(runtime)) - table.replaceContainers(endpoints) - if FileManager.default.fileExists(atPath: KubernetesProvisioner.kubeconfigPath) { - self?.ensureKubeProxy() - table.replaceKube(await KubeServiceProxy.backends(suffix: suffix)) - } + let endpoints = await Self.containerEndpoints(runtime, suffix: suffix) + table.replaceContainers(endpoints) + if let self { + self.dns.replaceHostIPs(Self.machineDNSHosts(self.machines, suffix: suffix)) + } + if FileManager.default.fileExists(atPath: KubernetesProvisioner.kubeconfigPath) { + self?.ensureKubeProxy() + table.replaceKube(await KubeServiceProxy.backends(suffix: suffix)) } try? await Task.sleep(for: .seconds(3)) } } } - static let dnsPort: UInt16 = 15353 - static let httpProxyPort: UInt16 = 8080 - static let httpsProxyPort: UInt16 = 8443 + static let defaultDNSPort: UInt16 = 15353 + static let defaultHTTPProxyPort: UInt16 = 8080 + static let defaultHTTPSProxyPort: UInt16 = 8443 + static let dnsPortKey = "dory.dnsPort" + static let httpProxyPortKey = "dory.httpProxyPort" + static let httpsProxyPortKey = "dory.httpsProxyPort" + static let domainsEnabledKey = "dory.domainsEnabled" + // User-configurable: 8080 collides with common dev servers, and MDM-managed DNS can't be pointed + // at Dory, so the whole *.dory.local feature is turn-off-able (GitHub #2). + var dnsPort: UInt16 = AppStore.defaultDNSPort + var httpProxyPort: UInt16 = AppStore.defaultHTTPProxyPort + var httpsProxyPort: UInt16 = AppStore.defaultHTTPSProxyPort + var domainsEnabled = true @ObservationIgnored private var tlsProxy: DoryTLSProxy? private func startLocalNetworking() { + guard domainsEnabled else { return } guard !networkingStarted else { return } - // DNS on a high port (mDNSResponder owns :53/:5353); a consent-gated /etc/resolver entry - // with a `port` directive points the system resolver here, so DNS needs no root. + // DNS on a high port (mDNSResponder owns :53/:5353); the system resolver is pointed here via + // an /etc/resolver entry with a `port` directive, so DNS needs no root. do { - try dns.start(port: Self.dnsPort) - try reverseProxy.start(httpPort: Self.httpProxyPort) + try dns.start(port: dnsPort) + try reverseProxy.start(httpPort: httpProxyPort) } catch { actionError = "Local *.dory.local networking couldn't start (a port may be in use): \(error.localizedDescription)" return @@ -570,10 +528,22 @@ final class AppStore { Task.detached { await SharedVMProvisioner.ensureEmulation() } } + /// Applies changed networking settings (ports or the domains toggle): full teardown then restart + /// so listeners rebind on the new ports and machine bridges are re-registered cleanly. Restarting + /// through startPortForwarding cancels and re-drives the reconcile task, avoiding a double-register. + func applyNetworkingSettings(dnsPort: UInt16? = nil, httpProxyPort: UInt16? = nil, httpsProxyPort: UInt16? = nil, domainsEnabled: Bool? = nil) { + if let dnsPort { self.dnsPort = dnsPort; UserDefaults.standard.set(Int(dnsPort), forKey: Self.dnsPortKey) } + if let httpProxyPort { self.httpProxyPort = httpProxyPort; UserDefaults.standard.set(Int(httpProxyPort), forKey: Self.httpProxyPortKey) } + if let httpsProxyPort { self.httpsProxyPort = httpsProxyPort; UserDefaults.standard.set(Int(httpsProxyPort), forKey: Self.httpsProxyPortKey) } + if let domainsEnabled { self.domainsEnabled = domainsEnabled; UserDefaults.standard.set(domainsEnabled, forKey: Self.domainsEnabledKey) } + stopLocalNetworking() + startPortForwarding() + } + private func startTLS() { let table = domainTable let suffix = domainSuffix - let port = Self.httpsProxyPort + let port = httpsProxyPort // TLS wildcards match one label, so `*.dory.local` doesn't cover multi-level k8s Service // domains like `web.default.k8s.dory.local`. Per-namespace wildcards (issued once at // startup) cover the common namespaces without fragile live cert reloads. @@ -604,21 +574,39 @@ final class AppStore { private func stopLocalNetworking() { guard networkingStarted else { return } + dns.replaceHostIPs([:]) dns.stop(); reverseProxy.stop(); tlsProxy?.stop(); tlsProxy = nil if let proxy = kubeProxy, proxy.isRunning { proxy.terminate() } kubeProxy = nil for machine in hostBridge.watchedMachines() { hostBridge.stopWatching(machine: machine) } + credentialProxy.stopAll() networkingStarted = false } func registerMachineBridge(_ name: String) { try? FileManager.default.createDirectory(atPath: MachineService.bridgeHostDir(for: name), withIntermediateDirectories: true) hostBridge.startWatching(machine: name) + credentialProxy.start(machine: name) + replayRememberedUSB(machine: name) } func unregisterMachineBridge(_ name: String) { hostBridge.stopWatching(machine: name) + credentialProxy.stop(machine: name) portForwarder.teardownLoopback(forMachine: name) + usbReplayedMachines.remove(name) + } + + private func replayRememberedUSB(machine: String) { + guard !usbReplayedMachines.contains(machine) else { return } + let commands = usbAttachments.reattachCommands(for: machine) + usbReplayedMachines.insert(machine) + guard !commands.isEmpty else { return } + Task.detached(priority: .utility) { + for arguments in commands { + _ = await UsbDevicesView.runDory(arguments) + } + } } /// `.dory.local` → the published host port that reaches the container. Containers without a @@ -641,6 +629,16 @@ final class AppStore { return result } + nonisolated static func machineDNSHosts(_ machines: [Machine], suffix: String) -> [String: String] { + var result: [String: String] = [:] + for machine in machines where machine.status == .running { + guard DoryDNS.ipv4Bytes(machine.ip) != nil else { continue } + let host = DoryDNS.normalizeHost("\(machine.name).\(suffix)") + result[host] = machine.ip + } + return result + } + nonisolated static func publicPorts(fromContainersJSON data: Data) -> Set { struct Entry: Decodable { let Ports: [PortItem]? } struct PortItem: Decodable { let PublicPort: Int? } @@ -1604,25 +1602,45 @@ final class AppStore { var migrationBusy = false var migrationInventory: MigrationInventory? + /// Every source engine found on this host (OrbStack, Docker Desktop, Colima, …), so the user + /// picks which to import from instead of the app silently auto-selecting the top-priority socket. + var migrationSources: [DockerSourceEngine] = [] + var selectedMigrationSourcePath: String? + /// The last import result, so the UI can surface per-item failures instead of a bare count. + var migrationSummary: MigrationSummary? + + private func selectedMigrationRuntime() async -> DockerEngineRuntime? { + guard let path = selectedMigrationSourcePath else { return nil } + return await DockerEngineRuntime.detect(candidates: [path]) + } - /// Reads a host Docker-compatible engine (if any) without modifying it, to power the pre-flight + func selectMigrationSource(_ path: String) async { + selectedMigrationSourcePath = path + await loadMigrationPreflight() + } + + /// Reads the selected host engine (if any) without modifying it, to power the pre-flight /// "here's what will move, nothing will be deleted" screen. func loadMigrationPreflight() async { - guard let source = await DockerEngineRuntime.detect() else { + migrationSources = DockerEngineSocketDiscovery.availableSources() + if selectedMigrationSourcePath == nil + || !migrationSources.contains(where: { $0.socketPath == selectedMigrationSourcePath }) { + selectedMigrationSourcePath = migrationSources.first?.socketPath + } + guard let source = await selectedMigrationRuntime() else { migrationInventory = nil return } migrationInventory = await MigrationAssistant.preflight(from: source) } - /// Imports an existing Docker-compatible engine's images + containers into Dory's own - /// shared VM — the "switch to Dory" flow. The target is Dory's standalone engine, so afterwards - /// the source can be uninstalled. + /// Imports the selected engine's images + containers into Dory's own shared VM — the "switch to + /// Dory" flow. The target is Dory's standalone engine, so afterwards the source can be uninstalled. func importFromDocker() async { guard runtimeKind == .sharedVM else { migrationStatus = "Switch to Dory's shared VM first, then import"; return } guard !migrationBusy else { return } - guard let source = await DockerEngineRuntime.detect() else { - migrationStatus = "No Docker-compatible engine found to import from"; return + guard let source = await selectedMigrationRuntime() else { + migrationStatus = "Couldn't reach the selected source engine — is it running?"; return } migrationBusy = true defer { migrationBusy = false } @@ -1631,7 +1649,9 @@ final class AppStore { let summary = await MigrationAssistant.migrate(from: source, to: target) { message in Task { @MainActor in self.migrationStatus = message } } - migrationStatus = "Imported \(summary.imagesPulled.count) images, \(summary.containersMigrated.count) containers" + migrationSummary = summary + let base = "Imported \(summary.imagesImported.count) images, \(summary.containersMigrated.count) containers" + migrationStatus = summary.failures.isEmpty ? base : "\(base) — \(summary.failures.count) failed" await reload() } @@ -1706,6 +1726,8 @@ final class AppStore { guard runtimeKind != .mock else { machines = MockData.machines; return MockData.machines } guard runtimeKind.isDockerCompatible else { machines = []; return [] } machines = await machineService.list() + try? SSHConfigWriter().write(machines: machines) + dns.replaceHostIPs(Self.machineDNSHosts(machines, suffix: domainSuffix)) syncMachineStats() for machine in machines where machine.status == .running { registerMachineBridge(machine.name) diff --git a/Dory/Models/Models.swift b/Dory/Models/Models.swift index d680192..a042df8 100644 --- a/Dory/Models/Models.swift +++ b/Dory/Models/Models.swift @@ -379,7 +379,7 @@ enum DetailTab: String, CaseIterable, Identifiable, Sendable { } enum SettingsTab: String, CaseIterable, Identifiable, Sendable { - case general, engine, resources, network, migrate, about + case general, engine, resources, network, usb, migrate, about var id: String { rawValue } var label: String { switch self { @@ -387,6 +387,7 @@ enum SettingsTab: String, CaseIterable, Identifiable, Sendable { case .engine: "Docker Engine" case .resources: "Resources" case .network: "Network" + case .usb: "USB Devices" case .migrate: "Migrate & Compare" case .about: "About" } diff --git a/Dory/Net/CredentialBridge.swift b/Dory/Net/CredentialBridge.swift new file mode 100644 index 0000000..6182a82 --- /dev/null +++ b/Dory/Net/CredentialBridge.swift @@ -0,0 +1,203 @@ +import Foundation +import os + +enum CredentialBridgeLog { + static let logger = Logger(subsystem: "dev.dory.app", category: "credential-bridge") +} + +struct CredentialBridgePlan: Equatable, Sendable { + enum PlanError: Error, Equatable { + case invalidMachineName + case missingSSHAuthSock + case relativeSSHAuthSock + case unsafeSSHAuthSock + } + + let machine: String + let bridgeRoot: URL + let hostSSHAuthSock: String? + + init(machine: String, bridgeRoot: URL, hostSSHAuthSock: String? = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"]) throws { + guard Self.isValidMachineName(machine) else { throw PlanError.invalidMachineName } + if let hostSSHAuthSock, !hostSSHAuthSock.isEmpty { + guard hostSSHAuthSock.hasPrefix("/") else { throw PlanError.relativeSSHAuthSock } + guard Self.isSocatSafeAddress(hostSSHAuthSock) else { throw PlanError.unsafeSSHAuthSock } + self.hostSSHAuthSock = hostSSHAuthSock + } else { + self.hostSSHAuthSock = nil + } + self.machine = machine + self.bridgeRoot = bridgeRoot + } + + var credentialDirectory: URL { + bridgeRoot.appendingPathComponent(machine).appendingPathComponent("credentials") + } + + var hostAgentProxySocket: URL { + credentialDirectory.appendingPathComponent("ssh-agent.sock") + } + + var hostGitAskpassSocket: URL { + credentialDirectory.appendingPathComponent("git-askpass.sock") + } + + var guestSSHAuthSock: String { + "\(DoryCredentialShim.bridgeGuestDir)/credentials/ssh-agent.sock" + } + + var guestGitAskpassSocket: String { + "\(DoryCredentialShim.bridgeGuestDir)/credentials/git-askpass.sock" + } + + var guestEnv: [String: String] { + [ + "SSH_AUTH_SOCK": guestSSHAuthSock, + "GIT_ASKPASS": DoryCredentialShim.gitAskpassPath, + "DORY_GIT_ASKPASS_SOCK": guestGitAskpassSocket, + ] + } + + func validateHostAgent() throws { + guard let hostSSHAuthSock, !hostSSHAuthSock.isEmpty else { throw PlanError.missingSSHAuthSock } + } + + var sshAgentProxyCommand: [String]? { + guard let hostSSHAuthSock else { return nil } + return [ + "socat", + "UNIX-LISTEN:\(hostAgentProxySocket.path),fork,unlink-early,mode=0600", + "UNIX-CONNECT:\(hostSSHAuthSock)", + ] + } + + static func isValidMachineName(_ value: String) -> Bool { + guard !value.isEmpty, value.count <= 63 else { return false } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == "." } + } + + static func isSocatSafeAddress(_ value: String) -> Bool { + guard !value.isEmpty else { return false } + for character in value.unicodeScalars { + if character == "," || character == "!" { return false } + if CharacterSet.whitespacesAndNewlines.contains(character) { return false } + } + return true + } +} + +enum HostCredentialBridge { + static func prepare(_ plan: CredentialBridgePlan) throws { + try FileManager.default.createDirectory(at: plan.credentialDirectory, withIntermediateDirectories: true) + try? FileManager.default.removeItem(at: plan.hostAgentProxySocket) + try? FileManager.default.removeItem(at: plan.hostGitAskpassSocket) + } +} + +final class CredentialProxyManager: @unchecked Sendable { + private let bridgeRoot: URL + private let lock = NSLock() + private var processes: [String: Process] = [:] + + init(bridgeRoot: URL) { + self.bridgeRoot = bridgeRoot + } + + func start(machine: String) { + lock.lock() + let alreadyRunning = processes[machine]?.isRunning == true + lock.unlock() + guard !alreadyRunning else { return } + + guard let socat = Self.socatPath() else { + CredentialBridgeLog.logger.error("credential forwarding for \(machine, privacy: .public) not started: socat not found in /opt/homebrew/bin, /usr/local/bin, or /usr/bin") + return + } + + let plan: CredentialBridgePlan + do { + plan = try CredentialBridgePlan(machine: machine, bridgeRoot: bridgeRoot) + } catch { + CredentialBridgeLog.logger.error("credential forwarding for \(machine, privacy: .public) not started: invalid plan: \(String(describing: error), privacy: .public)") + return + } + + guard var command = plan.sshAgentProxyCommand else { + CredentialBridgeLog.logger.error("credential forwarding for \(machine, privacy: .public) not started: no SSH_AUTH_SOCK in the host environment (start Dory from a shell with a running ssh-agent)") + return + } + + do { + try plan.validateHostAgent() + try HostCredentialBridge.prepare(plan) + command[0] = socat + let process = Process() + process.executableURL = URL(fileURLWithPath: command[0]) + process.arguments = Array(command.dropFirst()) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + lock.lock() + processes[machine] = process + lock.unlock() + } catch { + CredentialBridgeLog.logger.error("credential forwarding for \(machine, privacy: .public) failed to start: \(String(describing: error), privacy: .public)") + } + } + + func stop(machine: String) { + lock.lock() + let process = processes.removeValue(forKey: machine) + lock.unlock() + if let process, process.isRunning { process.terminate() } + } + + func stopAll() { + lock.lock() + let running = processes + processes.removeAll() + lock.unlock() + for process in running.values where process.isRunning { process.terminate() } + } + + func activeMachines() -> Set { + lock.lock() + defer { lock.unlock() } + return Set(processes.keys.filter { processes[$0]?.isRunning == true }) + } + + static func socatPath(fileManager: FileManager = .default) -> String? { + for path in ["/opt/homebrew/bin/socat", "/usr/local/bin/socat", "/usr/bin/socat"] where fileManager.isExecutableFile(atPath: path) { + return path + } + return nil + } +} + +enum DoryCredentialShim { + static let bridgeGuestDir = "/opt/dory/bridge" + static let envPath = "/etc/profile.d/dory-credentials.sh" + static let gitAskpassPath = "/usr/local/bin/dory-git-askpass" + + static let gitAskpassScript = ##""" +#!/bin/sh +SOCK="${DORY_GIT_ASKPASS_SOCK:-/opt/dory/bridge/credentials/git-askpass.sock}" +[ -S "$SOCK" ] || exit 1 +prompt="${1:-Password:}" +if command -v socat >/dev/null 2>&1; then + printf '%s\n' "$prompt" | socat - "UNIX-CONNECT:$SOCK" +else + exit 1 +fi +"""## + + static func installCommands() -> [String] { + [ + "install -d /usr/local/bin /etc/profile.d /opt/dory/bridge/credentials", + "cat > \(gitAskpassPath) <<'DORYGITASKPASSEOF'\n\(gitAskpassScript)\nDORYGITASKPASSEOF", + "chmod +x \(gitAskpassPath)", + "cat > \(envPath) <<'DORYCREDENTIALSEOF'\nexport SSH_AUTH_SOCK=/opt/dory/bridge/credentials/ssh-agent.sock\nexport GIT_ASKPASS=/usr/local/bin/dory-git-askpass\nexport DORY_GIT_ASKPASS_SOCK=/opt/dory/bridge/credentials/git-askpass.sock\nDORYCREDENTIALSEOF", + "chmod 644 \(envPath)", + ] + } +} diff --git a/Dory/Net/DoryDNS.swift b/Dory/Net/DoryDNS.swift index fb6efd7..ed72235 100644 --- a/Dory/Net/DoryDNS.swift +++ b/Dory/Net/DoryDNS.swift @@ -4,9 +4,9 @@ import Darwin #endif /// A minimal UDP DNS resolver that answers `*.` (default `*.dory.local`) with the loopback -/// address, so container domains resolve to the host where Dory's reverse proxy is listening — the -/// mechanism behind OrbStack's automatic `name.orb.local` domains. Non-matching queries are -/// refused so the system resolver falls through to real DNS. +/// address, so container domains resolve to the host where Dory's reverse proxy is listening. Exact +/// host overrides let machine names resolve to their guest IPs while other names keep using loopback. +/// Non-matching queries are refused so the system resolver falls through to real DNS. /// /// Binds an unprivileged port by default (validated end to end); production binds :53 and adds an /// `/etc/resolver/` entry, which is the consent-gated system change. @@ -16,6 +16,7 @@ final class DoryDNS: @unchecked Sendable { private let lock = NSLock() private var fd: Int32 = -1 private var running = false + private var hostIPs: [String: String] = [:] init(suffix: String = "dory.local", address: String = "127.0.0.1") { self.suffix = suffix.lowercased() @@ -44,6 +45,15 @@ final class DoryDNS: @unchecked Sendable { if socketFD >= 0 { Darwin.close(socketFD) } } + func replaceHostIPs(_ entries: [String: String]) { + let normalized = entries.reduce(into: [String: String]()) { result, pair in + let host = Self.normalizeHost(pair.key) + guard !host.isEmpty, Self.ipv4Bytes(pair.value) != nil else { return } + result[host] = pair.value + } + lock.lock(); hostIPs = normalized; lock.unlock() + } + private func isRunning() -> Bool { lock.lock(); defer { lock.unlock() }; return running } private func serve(_ socketFD: Int32) { @@ -57,7 +67,8 @@ final class DoryDNS: @unchecked Sendable { } } if n <= 0 { if isRunning() { continue } else { break } } - guard let response = Self.makeResponse(Array(buffer[0.. [UInt8]? { + makeResponse(query, suffix: suffix, ip: ip, hostIPs: [:]) + } + + /// Build a DNS answer for an A query whose name ends with the suffix; nil otherwise. + /// `hostIPs` is an exact-name override table for guests that should resolve directly. + nonisolated static func makeResponse(_ query: [UInt8], suffix: String, ip: String, hostIPs: [String: String]) -> [UInt8]? { guard query.count >= 12 else { return nil } let qdcount = (Int(query[4]) << 8) | Int(query[5]) guard qdcount >= 1 else { return nil } @@ -88,6 +105,8 @@ final class DoryDNS: @unchecked Sendable { let name = labels.joined(separator: ".").lowercased() // Only answer A/AAAA queries inside our suffix; refuse everything else so DNS falls through. guard name.hasSuffix(suffix), qtype == 1 || qtype == 28 else { return nil } + let answerIP = hostIPs[normalizeHost(name)] ?? ip + guard ipv4Bytes(answerIP) != nil else { return nil } let questionEnd = index + 4 var response = [UInt8]() @@ -107,10 +126,27 @@ final class DoryDNS: @unchecked Sendable { response.append(0x00); response.append(0x01) // CLASS IN response.append(contentsOf: [0x00, 0x00, 0x00, 0x1E]) // TTL 30s response.append(0x00); response.append(0x04) // RDLENGTH 4 - for part in ip.split(separator: ".") { response.append(UInt8(part) ?? 0) } + response.append(contentsOf: ipv4Bytes(answerIP) ?? [0, 0, 0, 0]) } return response } + private func currentHostIPs() -> [String: String] { + lock.lock(); defer { lock.unlock() } + return hostIPs + } + + nonisolated static func normalizeHost(_ host: String) -> String { + let lowered = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return lowered.hasSuffix(".") ? String(lowered.dropLast()) : lowered + } + + nonisolated static func ipv4Bytes(_ ip: String) -> [UInt8]? { + let parts = ip.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + let bytes = parts.compactMap { UInt8($0) } + return bytes.count == 4 ? bytes : nil + } + enum DNSError: Error, Sendable { case socket(String) } } diff --git a/Dory/Net/ExposeTunnel.swift b/Dory/Net/ExposeTunnel.swift new file mode 100644 index 0000000..1bbc3eb --- /dev/null +++ b/Dory/Net/ExposeTunnel.swift @@ -0,0 +1,75 @@ +import Foundation + +struct ExposeTunnelPlan: Equatable, Sendable { + enum Target: Equatable, Sendable { + case localPort(UInt16) + case machine(name: String, port: UInt16) + } + + enum PlanError: Error, Equatable { + case invalidPort + case invalidMachineName + case invalidHostname + case unsupportedScheme + } + + let target: Target + let hostname: String? + let scheme: String + + init(target: Target, hostname: String? = nil, scheme: String = "http") throws { + guard scheme == "http" || scheme == "https" else { throw PlanError.unsupportedScheme } + if let hostname { + guard Self.isValidHostname(hostname) else { throw PlanError.invalidHostname } + } + switch target { + case .localPort(let port): + guard port > 0 else { throw PlanError.invalidPort } + case .machine(let name, let port): + guard Self.isValidMachineName(name) else { throw PlanError.invalidMachineName } + guard port > 0 else { throw PlanError.invalidPort } + } + self.target = target + self.hostname = hostname + self.scheme = scheme + } + + var url: String { + switch target { + case .localPort(let port): + return "\(scheme)://127.0.0.1:\(port)" + case .machine(let name, let port): + return "\(scheme)://\(name).dory.local:\(port)" + } + } + + var cloudflaredCommand: [String] { + if let hostname { + return ["cloudflared", "tunnel", "--hostname", hostname, "--url", url, "run"] + } + return ["cloudflared", "tunnel", "--url", url] + } + + static func isValidMachineName(_ value: String) -> Bool { + guard !value.isEmpty, value.count <= 63 else { return false } + guard value.first?.isLetter == true || value.first?.isNumber == true, + value.last?.isLetter == true || value.last?.isNumber == true else { + return false + } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } + } + + static func isValidHostname(_ value: String) -> Bool { + guard value.count <= 253, !value.hasPrefix("."), !value.hasSuffix(".") else { return false } + let labels = value.split(separator: ".", omittingEmptySubsequences: false) + guard labels.count >= 2 else { return false } + return labels.allSatisfy { label in + guard (1...63).contains(label.count), + label.first?.isLetter == true || label.first?.isNumber == true, + label.last?.isLetter == true || label.last?.isNumber == true else { + return false + } + return label.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" } + } + } +} diff --git a/Dory/Net/HostBridge.swift b/Dory/Net/HostBridge.swift index fac76cd..829b39f 100644 --- a/Dory/Net/HostBridge.swift +++ b/Dory/Net/HostBridge.swift @@ -92,7 +92,8 @@ final class HostBridgeWatcher: @unchecked Sendable { guard !already else { return } let openDir = bridgeRoot.appendingPathComponent(machine).appendingPathComponent("open") let forwardDir = bridgeRoot.appendingPathComponent(machine).appendingPathComponent("forward") - for dir in [openDir, forwardDir] { + let credentialDir = bridgeRoot.appendingPathComponent(machine).appendingPathComponent("credentials") + for dir in [openDir, forwardDir, credentialDir] { try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) } var made: [DispatchSourceFileSystemObject] = [] diff --git a/Dory/Net/TunRouter.swift b/Dory/Net/TunRouter.swift new file mode 100644 index 0000000..082bc71 --- /dev/null +++ b/Dory/Net/TunRouter.swift @@ -0,0 +1,208 @@ +import Foundation + +/// Pure planning for Dory's direct-IP routing. The privileged helper owns applying the plan +/// (utun creation, route changes, and packet bridge startup); this type keeps validation and +/// command construction deterministic and testable in the app target. +struct TunRouter: Sendable { + struct Plan: Sendable, Equatable { + let interfaceName: String + let subnetCIDR: String + let hostGateway: String + let gateway: String + let interfaceCommand: [String] + let routeCommand: [String] + let teardownCommand: [String] + let enableNetworkingArguments: [String] + let disableNetworkingArguments: [String] + } + + enum RouterError: Error, Equatable { + case invalidInterface + case invalidCIDR + case invalidHostGateway + case invalidGateway + case unsupportedCIDR + } + + static func plan(interfaceName: String = "utun-dory", subnetCIDR: String, hostGateway: String = "192.168.127.1", gateway: String) throws -> Plan { + guard isValidInterfaceName(interfaceName) else { throw RouterError.invalidInterface } + guard isValidIPv4CIDR(subnetCIDR) else { throw RouterError.invalidCIDR } + guard isValidIPv4(hostGateway) else { throw RouterError.invalidHostGateway } + guard isValidIPv4(gateway) else { throw RouterError.invalidGateway } + + return Plan( + interfaceName: interfaceName, + subnetCIDR: subnetCIDR, + hostGateway: hostGateway, + gateway: gateway, + interfaceCommand: ["/sbin/ifconfig", interfaceName, "inet", hostGateway, gateway, "up"], + routeCommand: ["/sbin/route", "-n", "add", "-net", subnetCIDR, "-interface", interfaceName], + teardownCommand: ["/sbin/route", "-n", "delete", "-net", subnetCIDR], + enableNetworkingArguments: ["--direct-ip", "--container-subnet", subnetCIDR, "--host-gateway", hostGateway, "--guest-gateway", gateway], + disableNetworkingArguments: ["--remove", "--direct-ip", "--container-subnet", subnetCIDR] + ) + } + + static func isValidInterfaceName(_ value: String) -> Bool { + guard !value.isEmpty, value.count <= 15 else { return false } + return value.allSatisfy { $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" } + } + + static func isValidIPv4CIDR(_ value: String) -> Bool { + let parts = value.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, let prefix = Int(parts[1]), (0...32).contains(prefix) else { return false } + return isValidIPv4(String(parts[0])) + } + + static func isValidIPv4(_ value: String) -> Bool { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return false } + return parts.allSatisfy { part in + guard !part.isEmpty, let octet = Int(part), (0...255).contains(octet) else { return false } + return String(octet) == part || part == "0" + } + } + + struct IPv4Route: Sendable, Equatable { + let network: UInt32 + let prefixLength: Int + + init(cidr: String) throws { + let parts = cidr.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, + let prefix = Int(parts[1]), + (1...32).contains(prefix), + let address = IPv4Address(String(parts[0])) else { + throw RouterError.invalidCIDR + } + self.prefixLength = prefix + self.network = address.rawValue & Self.mask(for: prefix) + } + + func contains(_ address: IPv4Address) -> Bool { + (address.rawValue & Self.mask(for: prefixLength)) == network + } + + private static func mask(for prefix: Int) -> UInt32 { + UInt32.max << UInt32(32 - prefix) + } + } + + struct IPv4Address: Sendable, Equatable, Hashable, CustomStringConvertible { + let rawValue: UInt32 + + init?(_ value: String) { + guard TunRouter.isValidIPv4(value) else { return nil } + let octets = value.split(separator: ".").compactMap { UInt8($0) } + guard octets.count == 4 else { return nil } + rawValue = UInt32(octets[0]) << 24 | UInt32(octets[1]) << 16 | UInt32(octets[2]) << 8 | UInt32(octets[3]) + } + + init(rawValue: UInt32) { + self.rawValue = rawValue + } + + var description: String { + [ + UInt8((rawValue >> 24) & 0xff), + UInt8((rawValue >> 16) & 0xff), + UInt8((rawValue >> 8) & 0xff), + UInt8(rawValue & 0xff), + ].map(String.init).joined(separator: ".") + } + } + + struct IPv4Packet: Sendable, Equatable { + let source: IPv4Address + let destination: IPv4Address + let protocolNumber: UInt8 + let bytes: Data + + init?(bytes: Data) { + guard bytes.count >= 20 else { return nil } + let versionAndIHL = bytes[bytes.startIndex] + guard versionAndIHL >> 4 == 4 else { return nil } + let headerLength = Int(versionAndIHL & 0x0f) * 4 + guard headerLength >= 20, bytes.count >= headerLength else { return nil } + let totalLength = Int(UInt16(bytes[bytes.startIndex + 2]) << 8 | UInt16(bytes[bytes.startIndex + 3])) + guard totalLength >= headerLength, bytes.count >= totalLength else { return nil } + self.protocolNumber = bytes[bytes.startIndex + 9] + self.source = IPv4Address(rawValue: Self.readUInt32(bytes, offset: 12)) + self.destination = IPv4Address(rawValue: Self.readUInt32(bytes, offset: 16)) + self.bytes = bytes.prefix(totalLength) + } + + private static func readUInt32(_ data: Data, offset: Int) -> UInt32 { + let start = data.startIndex + offset + return UInt32(data[start]) << 24 + | UInt32(data[start + 1]) << 16 + | UInt32(data[start + 2]) << 8 + | UInt32(data[start + 3]) + } + } + + enum PacketBridgeDecision: Sendable, Equatable { + case injectToGuest(packet: Data, destination: IPv4Address) + case ignore(reason: String) + } + + struct PacketBridge: Sendable { + static let utunIPv4Header = Data([0, 0, 0, 2]) + static let bridgeMAC: [UInt8] = [0x5a, 0x94, 0xef, 0xd0, 0x12, 0x01] + static let guestMAC: [UInt8] = [0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xee] + + let route: IPv4Route + let gateway: IPv4Address + + init(subnetCIDR: String, gateway: String) throws { + self.route = try IPv4Route(cidr: subnetCIDR) + guard let gatewayAddress = IPv4Address(gateway) else { throw RouterError.invalidGateway } + self.gateway = gatewayAddress + } + + func classifyOutboundUtunFrame(_ frame: Data) -> PacketBridgeDecision { + guard let packet = Self.ipv4Packet(fromUtunFrame: frame) else { + return .ignore(reason: "not an IPv4 utun frame") + } + guard route.contains(packet.destination) else { + return .ignore(reason: "destination outside routed subnet") + } + guard packet.destination != gateway else { + return .ignore(reason: "destination is direct-IP gateway") + } + return .injectToGuest(packet: packet.bytes, destination: packet.destination) + } + + func wrapInboundPacketForUtun(_ packet: Data) -> Data? { + guard IPv4Packet(bytes: packet) != nil else { return nil } + return Self.utunIPv4Header + packet + } + + func ethernetFrameForGvproxy(_ packet: Data) -> Data? { + guard IPv4Packet(bytes: packet) != nil else { return nil } + var frame = Data() + frame.append(contentsOf: Self.guestMAC) + frame.append(contentsOf: Self.bridgeMAC) + frame.append(contentsOf: [0x08, 0x00]) + frame.append(packet) + return frame + } + + func ipv4PacketFromGvproxyFrame(_ frame: Data) -> Data? { + guard frame.count >= 34 else { return nil } + let etherTypeOffset = frame.startIndex + 12 + guard frame[etherTypeOffset] == 0x08, frame[etherTypeOffset + 1] == 0x00 else { return nil } + let packet = frame.dropFirst(14) + guard let parsed = IPv4Packet(bytes: packet) else { return nil } + return parsed.bytes + } + + private static func ipv4Packet(fromUtunFrame frame: Data) -> IPv4Packet? { + guard frame.count >= utunIPv4Header.count + 20, + frame.prefix(utunIPv4Header.count) == utunIPv4Header else { + return nil + } + return IPv4Packet(bytes: frame.dropFirst(utunIPv4Header.count)) + } + } +} diff --git a/Dory/Net/UsbAttachmentStore.swift b/Dory/Net/UsbAttachmentStore.swift new file mode 100644 index 0000000..960e475 --- /dev/null +++ b/Dory/Net/UsbAttachmentStore.swift @@ -0,0 +1,98 @@ +import Foundation + +struct UsbAttachment: Codable, Equatable, Identifiable, Sendable { + var machine: String + var busID: String + var port: Int + var createdAt: Date + + var id: String { "\(machine):\(busID)" } +} + +enum UsbAttachmentStoreError: Error, Equatable { + case invalidMachine + case invalidBusID + case invalidPort +} + +struct UsbAttachmentStore: Sendable { + private let defaults: UserDefaults + private let key: String + + init(defaults: UserDefaults = .standard, key: String = "dev.dory.usb.attachments") { + self.defaults = defaults + self.key = key + } + + func attachments() -> [UsbAttachment] { + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode([UsbAttachment].self, from: data) else { + return [] + } + return decoded.sorted { lhs, rhs in + lhs.machine == rhs.machine ? lhs.busID < rhs.busID : lhs.machine < rhs.machine + } + } + + func attachments(for machine: String) -> [UsbAttachment] { + let normalized = machine.trimmingCharacters(in: .whitespacesAndNewlines) + return attachments().filter { $0.machine == normalized } + } + + func remember(machine: String, busID: String, port: Int, now: Date = Date()) throws -> UsbAttachment { + let machine = try Self.normalizedMachine(machine) + let busID = try Self.normalizedBusID(busID) + guard (0...65_535).contains(port) else { throw UsbAttachmentStoreError.invalidPort } + var all = attachments() + let attachment = UsbAttachment(machine: machine, busID: busID, port: port, createdAt: now) + all.removeAll { $0.id == attachment.id } + all.append(attachment) + try save(all) + return attachment + } + + func forget(machine: String, busID: String) throws { + let machine = try Self.normalizedMachine(machine) + let busID = try Self.normalizedBusID(busID) + try save(attachments().filter { !($0.machine == machine && $0.busID == busID) }) + } + + func forgetMachine(_ machine: String) throws { + let machine = try Self.normalizedMachine(machine) + try save(attachments().filter { $0.machine != machine }) + } + + func reattachCommands(for machine: String) -> [[String]] { + attachments(for: machine).map { + ["usb", "attach", $0.busID, "--port", "\($0.port)", "--machine", $0.machine] + } + } + + private func save(_ attachments: [UsbAttachment]) throws { + let sorted = attachments.sorted { lhs, rhs in + lhs.machine == rhs.machine ? lhs.busID < rhs.busID : lhs.machine < rhs.machine + } + defaults.set(try JSONEncoder().encode(sorted), forKey: key) + } + + static func normalizedMachine(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.count <= 64, + trimmed.first?.isLetter == true || trimmed.first?.isNumber == true, + trimmed.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "." || $0 == "-" }) else { + throw UsbAttachmentStoreError.invalidMachine + } + return trimmed + } + + static func normalizedBusID(_ value: String) throws -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + trimmed.count <= 128, + trimmed.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" || $0 == "." || $0 == "-" || $0 == ":" || $0 == "/" }) else { + throw UsbAttachmentStoreError.invalidBusID + } + return trimmed + } +} diff --git a/Dory/Resources/Recipes/docker-host.yaml b/Dory/Resources/Recipes/docker-host.yaml new file mode 100644 index 0000000..6031fa8 --- /dev/null +++ b/Dory/Resources/Recipes/docker-host.yaml @@ -0,0 +1,16 @@ +name: docker-host +summary: Machine wired for Dory Docker socket access and CLI workflows +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 4, memory: 8GiB, disk: 80GiB} +packages: [git, curl, ca-certificates, jq, socat] +runcmd: + - ARCH=$(dpkg --print-architecture); DARCH=$([ "$ARCH" = arm64 ] && echo aarch64 || echo x86_64); curl -fsSL https://download.docker.com/linux/static/stable/$DARCH/docker-27.5.1.tgz | tar -xz -C /tmp + - install -m0755 /tmp/docker/docker /usr/local/bin/docker +mounts: + - ~/Projects:~/Projects +ports: [2375, 8080] +env: {DOCKER_HOST: unix:///var/run/dory/docker.sock} +ssh: {agent_forward: true} +docker: true +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/go.yaml b/Dory/Resources/Recipes/go.yaml new file mode 100644 index 0000000..db93748 --- /dev/null +++ b/Dory/Resources/Recipes/go.yaml @@ -0,0 +1,16 @@ +name: go +summary: Go toolchain with Git and common C build dependencies +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 4, memory: 8GiB, disk: 60GiB} +packages: [build-essential, git, curl, ca-certificates] +runcmd: + - ARCH=$(dpkg --print-architecture); curl -fsSL https://go.dev/dl/go1.23.4.linux-${ARCH}.tar.gz | tar -C /usr/local -xz + - printf '%s\n' 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh +mounts: + - ~/Projects:~/Projects +ports: [8080] +env: {GOPATH: /home/{{user}}/go} +ssh: {agent_forward: true} +docker: false +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/k8s-lab.yaml b/Dory/Resources/Recipes/k8s-lab.yaml new file mode 100644 index 0000000..31c3f60 --- /dev/null +++ b/Dory/Resources/Recipes/k8s-lab.yaml @@ -0,0 +1,16 @@ +name: k8s-lab +summary: Kubernetes lab with kubectl, Docker CLI, and common debugging tools +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 6, memory: 12GiB, disk: 80GiB} +packages: [git, curl, ca-certificates, jq, socat, iproute2, dnsutils] +runcmd: + - ARCH=$(dpkg --print-architecture); curl -fsSL -o /usr/local/bin/kubectl https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$ARCH/kubectl + - chmod +x /usr/local/bin/kubectl +mounts: + - ~/Projects:~/Projects +ports: [8001, 8080, 30000] +env: {KUBECONFIG: /home/{{user}}/.kube/config} +ssh: {agent_forward: true} +docker: true +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/node.yaml b/Dory/Resources/Recipes/node.yaml new file mode 100644 index 0000000..eed442d --- /dev/null +++ b/Dory/Resources/Recipes/node.yaml @@ -0,0 +1,17 @@ +name: node +summary: Node.js LTS with Corepack and common native build dependencies +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 4, memory: 8GiB, disk: 60GiB} +packages: [build-essential, git, curl, ca-certificates, python3, pkg-config] +runcmd: + - curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - + - apt-get install -y nodejs + - corepack enable +mounts: + - ~/Projects:~/Projects +ports: [3000, 5173, 8080] +env: {NODE_ENV: development} +ssh: {agent_forward: true} +docker: false +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/python-ml.yaml b/Dory/Resources/Recipes/python-ml.yaml new file mode 100644 index 0000000..4c0aea8 --- /dev/null +++ b/Dory/Resources/Recipes/python-ml.yaml @@ -0,0 +1,15 @@ +name: python-ml +summary: Python data and ML workspace with venv, pipx, and native build tools +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 6, memory: 12GiB, disk: 80GiB} +packages: [build-essential, python3, python3-pip, python3-venv, pipx, git, curl, libopenblas-dev] +runcmd: + - python3 -m pip install --upgrade pip +mounts: + - ~/Projects:~/Projects +ports: [8888, 5000] +env: {PIPX_HOME: /home/{{user}}/.local/pipx} +ssh: {agent_forward: true} +docker: false +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/rust.yaml b/Dory/Resources/Recipes/rust.yaml new file mode 100644 index 0000000..aa91e7a --- /dev/null +++ b/Dory/Resources/Recipes/rust.yaml @@ -0,0 +1,15 @@ +name: rust +summary: Rust toolchain with OpenSSL and native build dependencies +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 4, memory: 8GiB, disk: 60GiB} +packages: [build-essential, pkg-config, libssl-dev, git, curl, ca-certificates] +runcmd: + - curl https://sh.rustup.rs -sSf | sh -s -- -y +mounts: + - ~/Projects:~/Projects +ports: [3000, 8080] +env: {CARGO_HOME: /home/{{user}}/.cargo} +ssh: {agent_forward: true} +docker: false +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Resources/Recipes/ubuntu-dev.yaml b/Dory/Resources/Recipes/ubuntu-dev.yaml new file mode 100644 index 0000000..ccc5ae4 --- /dev/null +++ b/Dory/Resources/Recipes/ubuntu-dev.yaml @@ -0,0 +1,14 @@ +name: ubuntu-dev +summary: Ubuntu development base with common build and network tools +distro: ubuntu:24.04 +arch: arm64 +resources: {cpus: 4, memory: 8GiB, disk: 60GiB} +packages: [build-essential, git, curl, ca-certificates, pkg-config, libssl-dev, jq, vim] +runcmd: [] +mounts: + - ~/Projects:~/Projects +ports: [3000, 8080] +env: {} +ssh: {agent_forward: true} +docker: false +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} diff --git a/Dory/Runtime/Apple/AppleContainerRuntime.swift b/Dory/Runtime/Apple/AppleContainerRuntime.swift deleted file mode 100644 index 091fcd2..0000000 --- a/Dory/Runtime/Apple/AppleContainerRuntime.swift +++ /dev/null @@ -1,383 +0,0 @@ -import Foundation - -struct AppleContainerRuntime: ContainerRuntime { - let kind: RuntimeKind = .appleContainer - let binary: String - - static func detect() async -> AppleContainerRuntime? { - guard let binary = Shell.find("container", candidates: ["/opt/homebrew/bin/container", "/usr/local/bin/container"]), - AppleContainerSupport.evaluate(platform: .current(), hasContainerCLI: true).isSupported else { return nil } - let status = await Shell.runAsyncResult(binary, ["system", "status"]) - guard status.exit == 0 else { return nil } - return AppleContainerRuntime(binary: binary) - } - - private func runJSON(_ arguments: [String], as type: T.Type) async throws -> T { - let output = try await Shell.runAsync(binary, arguments) - let jsonStart = output.firstIndex(where: { $0 == "[" || $0 == "{" }) ?? output.startIndex - return try JSONDecoder().decode(T.self, from: Data(output[jsonStart...].utf8)) - } - - func snapshot() async throws -> RuntimeSnapshot { - async let containersRaw = runJSON(["ls", "-a", "--format", "json"], as: [ACContainer].self) - async let imagesRaw = try? runJSON(["image", "ls", "--format", "json"], as: [ACImage].self) - async let volumesRaw = try? runJSON(["volume", "ls", "--format", "json"], as: [ACVolume].self) - async let machinesRaw = try? runJSON(["machine", "ls", "--format", "json"], as: [ACMachine].self) - async let versionRaw = try? Shell.runAsync(binary, ["--version"]) - - let containerList = try await containersRaw - let runningIDs = containerList.filter { $0.status?.state == "running" }.map(\.id) - let stats = await statsByID(runningIDs) - - let imageList = (await imagesRaw) ?? [] - let imageRefCounts = Dictionary(grouping: containerList.compactMap { $0.configuration?.image?.reference }, by: { $0 }).mapValues(\.count) - - let containers = containerList.map { map($0, stats: stats[$0.id]) } - let images = imageList.map { mapImage($0, usedBy: imageRefCounts[$0.configuration?.name ?? ""] ?? 0) } - let volumes = ((await volumesRaw) ?? []).map(mapVolume) - let machines = ((await machinesRaw) ?? []).map { acm in - let running = acm.status?.lowercased() == "running" - let (distro, letter, hex) = Self.distroInfo(acm.id) - return Machine( - name: acm.id, distro: distro, version: acm.`default` == true ? "default" : "", - status: running ? .running : .stopped, cpuPercent: 0, - memoryDisplay: DockerFormat.bytes(acm.memory), ip: acm.ipAddress ?? "—", - letter: letter, badgeHex: hex - ) - } - let networks = synthesizeNetworks(from: containerList) - let version = ((await versionRaw) ?? "").components(separatedBy: " ").last(where: { $0.first?.isNumber == true }) ?? "1.0.0" - - return RuntimeSnapshot(containers: containers, images: images, volumes: volumes, networks: networks, - pods: [], machines: machines, engineRunning: true, engineVersion: version) - } - - static func distroInfo(_ name: String) -> (distro: String, letter: String, hex: UInt32) { - let lower = name.lowercased() - if lower.contains("ubuntu") { return ("Ubuntu", "U", 0xE95420) } - if lower.contains("debian") { return ("Debian", "D", 0xA80030) } - if lower.contains("fedora") { return ("Fedora", "F", 0x3C6EB4) } - if lower.contains("arch") { return ("Arch Linux", "A", 0x1793D1) } - if lower.contains("alpine") { return ("Alpine", "A", 0x0D597F) } - let letter = name.first.map { String($0).uppercased() } ?? "L" - return ("Linux", letter, 0x2E9BF5) - } - - func startMachine(name: String) async throws { _ = try await Shell.runAsync(binary, ["machine", "start", name]) } - func stopMachine(name: String) async throws { _ = await Shell.runAsyncResult(binary, ["machine", "stop", name]) } - - private func statsByID(_ ids: [String]) async -> [String: ACStats] { - guard !ids.isEmpty else { return [:] } - guard let list = try? await runJSON(["stats", "--no-stream", "--format", "json"] + ids, as: [ACStats].self) else { return [:] } - return Dictionary(list.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a }) - } - - private func map(_ container: ACContainer, stats: ACStats?) -> Container { - let config = container.configuration - let running = container.status?.state == "running" - let ip = container.status?.networks?.first?.ipv4Address.map { String($0.split(separator: "/").first ?? "") } ?? "—" - let networks = container.status?.networks?.compactMap { network -> String? in - guard let name = network.network, !name.isEmpty else { return nil } - return name - } ?? [] - let networkEndpointSettings = Dictionary(uniqueKeysWithValues: networks.map { network in - var endpoint = DockerEndpointSettings() - if !ip.isEmpty, ip != "—" { - endpoint.IPAddress = ip - } - return (network, endpoint) - }) - let command = [config?.initProcess?.executable].compactMap { $0 }.joined() - + (config?.initProcess?.arguments.map { $0.isEmpty ? "" : " " + $0.joined(separator: " ") } ?? "") - let memLimit = stats?.memoryLimitBytes ?? config?.resources?.memoryInBytes - let memUsage = stats?.memoryUsageBytes - let fraction = (memUsage.flatMap { u in memLimit.map { l in l > 0 ? Double(u) / Double(l) : 0 } }) ?? 0 - let ports = (config?.publishedPorts ?? []).compactMap { port -> String? in - guard let container = port.containerPort else { return nil } - return ContainerPortDisplay.dockerDisplay( - hostPort: port.hostPort, - containerPort: container, - proto: port.proto - ) - }.joined(separator: ", ") - - return Container( - id: container.id, name: container.id, - image: config?.image?.reference ?? "—", - status: running ? .running : .stopped, - cpuPercent: 0, - memoryDisplay: running ? DockerFormat.bytes(memUsage) : "0 MB", - memoryLimitDisplay: memLimit.map(DockerFormat.bytes) ?? "—", - memoryFraction: fraction, - ports: ports.isEmpty ? "—" : ports, - uptime: running ? DockerFormat.uptime(iso: container.status?.startedDate) : "—", - created: DockerFormat.relative(iso: config?.creationDate), - ipAddress: ip.isEmpty ? "—" : ip, - domain: "\(container.id).dory.local", - command: command.isEmpty ? "—" : command, - restartPolicy: "—", - createdEpoch: nil, - labels: config?.labels ?? [:], - memoryBytes: running ? (memUsage ?? 0) : 0, - networks: networks, - networkEndpointSettings: networkEndpointSettings, - exitCode: container.status?.exitCode - ) - } - - private func mapImage(_ image: ACImage, usedBy: Int) -> DockerImage { - let reference = image.configuration?.name ?? image.id - let (repository, tag) = DockerRegistry.splitImageRef(reference) - let shortID = image.id.replacingOccurrences(of: "sha256:", with: "").prefix(12) - return DockerImage(repository: repository, tag: tag, imageID: String(shortID), - size: DockerFormat.bytes(image.configuration?.descriptor?.size), - created: DockerFormat.relative(iso: image.configuration?.creationDate), - usedByCount: usedBy, - sizeBytes: image.configuration?.descriptor?.size ?? 0, - labels: image.configuration?.labels ?? [:]) - } - - private func mapVolume(_ volume: ACVolume) -> Volume { - Volume(name: volume.configuration?.name ?? volume.id, size: "—", - driver: volume.configuration?.driver ?? "local", usedBy: "—", - created: DockerFormat.relative(iso: volume.configuration?.creationDate)) - } - - private func synthesizeNetworks(from containers: [ACContainer]) -> [DoryNetwork] { - var byName: [String: (subnet: String, count: Int)] = [:] - for container in containers { - for network in container.status?.networks ?? [] { - let name = network.network ?? "default" - let subnet = network.ipv4Gateway.map { gateway in - gateway.split(separator: ".").dropLast().joined(separator: ".") + ".0/24" - } ?? "—" - byName[name, default: (subnet, 0)].count += 1 - byName[name]?.subnet = subnet - } - } - return byName.keys.sorted().map { name in - DoryNetwork(name: name, driver: "bridge", scope: "local", subnet: byName[name]?.subnet ?? "—", containerCount: byName[name]?.count ?? 0) - } - } - - func start(containerID: String) async throws { _ = try await Shell.runAsync(binary, ["start", containerID]) } - func stop(containerID: String) async throws { _ = try await Shell.runAsync(binary, ["stop", containerID]) } - func restart(containerID: String) async throws { - _ = await Shell.runAsyncResult(binary, ["stop", containerID]) - _ = try await Shell.runAsync(binary, ["start", containerID]) - } - func remove(containerID: String) async throws { _ = await Shell.runAsyncResult(binary, ["delete", "-f", containerID]) } - func removeVolume(name: String) async throws { _ = await Shell.runAsyncResult(binary, ["volume", "delete", name]) } - func createNetwork(name: String, labels: [String: String]) async throws { - var arguments = ["network", "create"] - for (key, value) in labels.sorted(by: { $0.key < $1.key }) { - arguments += ["--label", "\(key)=\(value)"] - } - arguments.append(name) - _ = try await Shell.runAsync(binary, arguments) - } - func removeNetwork(name: String) async throws { - _ = try await Shell.runAsync(binary, ["network", "delete", name]) - } - func pruneNetworks() async throws { - _ = try await Shell.runAsync(binary, ["network", "prune"]) - } - func pull(image: String) async throws { _ = try await Shell.runAsync(binary, ["image", "pull", image]) } - func tagImage(source: String, repo: String, tag: String) async throws { - let target = tag.isEmpty ? repo : "\(repo):\(tag)" - _ = try await Shell.runAsync(binary, ["image", "tag", source, target]) - } - func pushImage(reference: String) async throws -> AsyncStream { - let binary = self.binary - return AsyncStream { continuation in - Task { - do { - let output = try await Shell.runAsync(binary, ["image", "push", "--progress", "plain", reference]) - for line in Self.pushProgressLines(output: output, reference: reference) { - continuation.yield(line) - } - } catch { - continuation.yield(Self.pushLine(error: "\(error)")) - } - continuation.finish() - } - } - } - - func logs(containerID: String) async throws -> [LogLine] { - let stamped = await Shell.runAsyncResult(binary, ["logs", "--timestamps", containerID]) - let output: String - if stamped.exit == 0 { - output = stamped.output - } else { - output = (try? await Shell.runAsync(binary, ["logs", containerID])) ?? "" - } - return AppleLogParse.parse(output) - } - - func streamLogs(containerID: String) -> AsyncStream { - let binary = self.binary - return AsyncStream { continuation in - final class LineBuffer: @unchecked Sendable { var data = Data() } - let buffer = LineBuffer() - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = ["logs", "--follow", containerID] - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - let reader = pipe.fileHandleForReading - reader.readabilityHandler = { handle in - let chunk = handle.availableData - guard !chunk.isEmpty else { return } - buffer.data.append(chunk) - while let newline = buffer.data.firstIndex(of: 0x0A) { - let lineData = buffer.data.subdata(in: buffer.data.startIndex.. [EnvVar] { - guard let list = try? await runJSON(["inspect", containerID, "--format", "json"], as: [ACContainer].self), - let environment = list.first?.configuration?.initProcess?.environment else { return [] } - return environment.map { entry in - if let eq = entry.firstIndex(of: "=") { - return EnvVar(key: String(entry[entry.startIndex.. ExecResult { - let result = await Shell.runAsyncResult(binary, ["exec", containerID] + command) - return ExecResult(exitCode: Int(result.exit), output: result.output) - } - - func containerExitCode(_ id: String) async -> Int? { - guard let list = try? await runJSON(["inspect", id, "--format", "json"], as: [ACContainer].self) else { return nil } - return list.first?.status?.exitCode - } - - private func configuredCPUCount(containerID: String) async -> Int? { - guard let list = try? await runJSON(["inspect", containerID, "--format", "json"], as: [ACContainer].self), - let cpus = list.first?.configuration?.resources?.cpus, - cpus > 0 else { return nil } - return cpus - } - - func create(_ spec: ContainerSpec) async throws -> String { - let output = try await Shell.runAsync(binary, Self.createArguments(for: spec)) - return output.trimmingCharacters(in: .whitespacesAndNewlines) - } - - nonisolated static func createArguments(for spec: ContainerSpec) -> [String] { - var arguments = ["create", "--name", spec.name] - if let platform = spec.platform?.trimmingCharacters(in: .whitespacesAndNewlines), !platform.isEmpty { - arguments += ["--platform", platform] - } - if let containerIDFile = spec.containerIDFile, !containerIDFile.isEmpty { arguments += ["--cidfile", containerIDFile] } - if let runtimeName = spec.runtimeName, !runtimeName.isEmpty { arguments += ["--runtime", runtimeName] } - for (key, value) in spec.environment.sorted(by: { $0.key < $1.key }) { arguments += ["-e", "\(key)=\(value)"] } - for port in spec.ports { arguments += ["-p", port] } - for (key, value) in spec.labels.sorted(by: { $0.key < $1.key }) { arguments += ["--label", "\(key)=\(value)"] } - if let user = spec.user, !user.isEmpty { arguments += ["--user", user] } - if let workingDir = spec.workingDir, !workingDir.isEmpty { arguments += ["--workdir", workingDir] } - if !spec.entrypoint.isEmpty { arguments += ["--entrypoint", spec.entrypoint.joined(separator: " ")] } - if spec.openStdin { arguments.append("--interactive") } - if spec.tty { arguments.append("--tty") } - if spec.autoRemove == true { arguments.append("--rm") } - if spec.initProcessEnabled == true || spec.resources.initProcessEnabled == true { arguments.append("--init") } - if spec.readonlyRootfs == true { arguments.append("--read-only") } - if let cpus = Self.cpuCount(from: spec.resources.nanoCPUs ?? spec.nanoCPUs) { arguments += ["--cpus", cpus] } - if let memory = spec.resources.memoryLimitBytes ?? spec.memoryLimitBytes { arguments += ["--memory", "\(memory)"] } - for cap in spec.capAdd { arguments += ["--cap-add", cap] } - for cap in spec.capDrop { arguments += ["--cap-drop", cap] } - for server in spec.dns { arguments += ["--dns", server] } - if let domainname = spec.domainname, !domainname.isEmpty { arguments += ["--dns-domain", domainname] } - for option in spec.dnsOptions { arguments += ["--dns-option", option] } - for domain in spec.dnsSearch { arguments += ["--dns-search", domain] } - if spec.networkDisabled == true { arguments.append("--no-dns") } - for network in Self.appleNetworks(spec) { arguments += ["--network", network] } - for volume in spec.volumes { arguments += ["--volume", volume] } - for mount in spec.mounts.compactMap(Self.appleMount) { arguments += ["--mount", mount] } - if let shmSize = spec.shmSize { arguments += ["--shm-size", "\(shmSize)"] } - for path in spec.tmpfs.keys.sorted() { arguments += ["--tmpfs", path] } - for ulimit in spec.resources.ulimits ?? [] { - if let encoded = Self.appleUlimit(ulimit) { arguments += ["--ulimit", encoded] } - } - arguments.append("--") - arguments.append(spec.image) - arguments += spec.command - return arguments - } - - private nonisolated static func cpuCount(from nanoCPUs: Int64?) -> String? { - guard let nanoCPUs, nanoCPUs > 0 else { return nil } - let cpus = Double(nanoCPUs) / 1_000_000_000 - return cpus == floor(cpus) ? String(Int(cpus)) : String(cpus) - } - - private nonisolated static func appleNetworks(_ spec: ContainerSpec) -> [String] { - if spec.networkDisabled == true { return ["none"] } - let explicit = [spec.networkMode].compactMap { $0 }.filter { !$0.isEmpty && $0 != "default" } - return explicit.isEmpty ? spec.networks : explicit - } - - private nonisolated static func appleMount(_ mount: ContainerMount) -> String? { - guard !mount.type.isEmpty, !mount.target.isEmpty else { return nil } - var parts = ["type=\(mount.type)", "target=\(mount.target)"] - if let source = mount.source, !source.isEmpty { parts.append("source=\(source)") } - if mount.readOnly { parts.append("readonly") } - return parts.joined(separator: ",") - } - - private nonisolated static func appleUlimit(_ limit: DockerUlimit) -> String? { - guard let name = limit.Name, !name.isEmpty else { return nil } - guard let soft = limit.Soft else { return nil } - if let hard = limit.Hard { return "\(name)=\(soft):\(hard)" } - return "\(name)=\(soft)" - } - - private static func pushProgressLines(output: String, reference: String) -> [Data] { - let lines = output.split(separator: "\n", omittingEmptySubsequences: true).map(String.init) - let statuses = lines.isEmpty ? ["Pushed \(reference)"] : lines - return statuses.map { pushLine(status: $0) } - } - - private static func pushLine(status: String) -> Data { - let data = (try? JSONSerialization.data(withJSONObject: ["status": status])) ?? Data(#"{"status":"push progress unavailable"}"#.utf8) - return data + Data("\n".utf8) - } - - private static func pushLine(error: String) -> Data { - let data = (try? JSONSerialization.data(withJSONObject: ["error": error])) ?? Data(#"{"error":"push failed"}"#.utf8) - return data + Data("\n".utf8) - } - - func sampleCPU(containerID: String) async -> Double? { - guard let first = try? await runJSON(["stats", "--no-stream", "--format", "json", containerID], as: [ACStats].self).first, - let a = first.cpuUsageUsec else { return nil } - try? await Task.sleep(for: .milliseconds(800)) - guard let second = try? await runJSON(["stats", "--no-stream", "--format", "json", containerID], as: [ACStats].self).first, - let b = second.cpuUsageUsec else { return nil } - let inspectedCPUs: Int? - if second.cpus == nil, first.cpus == nil { - inspectedCPUs = await configuredCPUCount(containerID: containerID) - } else { - inspectedCPUs = nil - } - let cpus = second.cpus ?? first.cpus ?? inspectedCPUs ?? 1 - return AppleStatsMath.cpuPercent(deltaUsec: b - a, elapsedUsec: 800_000, cpus: cpus) - } -} diff --git a/Dory/Runtime/Apple/AppleContainerSupport.swift b/Dory/Runtime/Apple/AppleContainerSupport.swift index ce850dd..02d156d 100644 --- a/Dory/Runtime/Apple/AppleContainerSupport.swift +++ b/Dory/Runtime/Apple/AppleContainerSupport.swift @@ -52,18 +52,19 @@ nonisolated struct RuntimeSupport: Equatable, Sendable { } } -enum AppleContainerSupport { - nonisolated static let minimumMajorVersion = 26 +/// Dory's own VMM (`dory-hv`) runs on Hypervisor.framework's in-kernel GICv3, which is available +/// from macOS 15, and needs no Apple `container` toolchain (it ships its own kernel + userspace +/// networking). So when the dory-hv engine is present it supports a strictly broader set of hosts +/// than the Virtualization.framework / Apple-container path. +enum DoryHVSupport { + nonisolated static let minimumMajorVersion = 15 - nonisolated static func evaluate(platform: MacHostPlatform, hasContainerCLI: Bool) -> RuntimeSupport { - guard platform.major >= minimumMajorVersion else { - return .unsupported("requires macOS 26 or later for Apple's container engine", issue: .osVersion) - } + nonisolated static func evaluate(platform: MacHostPlatform) -> RuntimeSupport { guard platform.isAppleSilicon else { - return .unsupported("requires Apple silicon for Apple's container engine", issue: .architecture) + return .unsupported("Dory's engine requires Apple silicon", issue: .architecture) } - guard hasContainerCLI else { - return .unsupported("needs Apple's container toolchain", issue: .missingToolchain) + guard platform.major >= minimumMajorVersion else { + return .unsupported("Dory's engine requires macOS 15 or later", issue: .osVersion) } return .supported } diff --git a/Dory/Runtime/Docker/DockerContext.swift b/Dory/Runtime/Docker/DockerContext.swift index aeed619..2f6fceb 100644 --- a/Dory/Runtime/Docker/DockerContext.swift +++ b/Dory/Runtime/Docker/DockerContext.swift @@ -9,9 +9,7 @@ enum DockerContext { private static let previousKey = "dory.previousDockerContext" private static func dockerBinary() -> String? { - Shell.find("docker", candidates: [ - "/opt/homebrew/bin/docker", "/usr/local/bin/docker", "/usr/bin/docker", - ]) + HostTools.docker() } static func activate(socketPath: String) async { diff --git a/Dory/Runtime/Docker/DockerEngineRuntime.swift b/Dory/Runtime/Docker/DockerEngineRuntime.swift index 412c00d..f8398be 100644 --- a/Dory/Runtime/Docker/DockerEngineRuntime.swift +++ b/Dory/Runtime/Docker/DockerEngineRuntime.swift @@ -74,6 +74,12 @@ enum DockerFormat { } } +struct DockerSourceEngine: Identifiable, Sendable, Equatable { + var id: String { socketPath } + var label: String + var socketPath: String +} + enum DockerEngineSocketDiscovery { private nonisolated struct DockerConfig: Decodable { var currentContext: String? @@ -104,7 +110,9 @@ enum DockerEngineSocketDiscovery { excluding excludedPaths: [String] = [] ) -> [String] { var paths: [String] = [] - let exclusions = Set(excludedPaths + [doryShimSocket(home: home)]) + // Exclude both Dory sockets so a migration never picks Dory as its own source: the user-facing + // shim socket (what the `dory` docker context points at) and the raw shared-VM engine socket. + let exclusions = Set(excludedPaths + [doryShimSocket(home: home), doryEngineSocket(home: home)]) if let path = unixPath(from: environment["DOCKER_HOST"]) { paths.append(path) @@ -126,6 +134,34 @@ enum DockerEngineSocketDiscovery { "\(home)/.dory/dory.sock" } + private nonisolated static func doryEngineSocket(home: String) -> String { + "\(home)/.dory/engine.sock" + } + + /// Every migration source engine present on this host, each labeled by vendor, so the user can + /// pick which one to import from instead of the app silently auto-selecting the highest-priority + /// socket (which is why "can't import from OrbStack" happened when Docker Desktop was also + /// installed — `/var/run/docker.sock` outranked OrbStack and there was no way to override it). + /// Only sockets whose file exists are returned; readiness is confirmed by a later probe. + nonisolated static func availableSources( + environment: [String: String] = ProcessInfo.processInfo.environment, + home: String = NSHomeDirectory(), + fileManager: FileManager = .default + ) -> [DockerSourceEngine] { + candidates(environment: environment, home: home, fileManager: fileManager) + .filter { fileManager.fileExists(atPath: $0) } + .map { DockerSourceEngine(label: engineLabel(for: $0, home: home), socketPath: $0) } + } + + nonisolated static func engineLabel(for path: String, home: String) -> String { + if path.hasPrefix("\(home)/.orbstack/") { return "OrbStack" } + if path.hasPrefix("\(home)/.colima/") { return "Colima" } + if path.hasPrefix("\(home)/.rd/") { return "Rancher Desktop" } + if path.contains("/podman") { return "Podman" } + if path == "/var/run/docker.sock" || path.hasPrefix("\(home)/.docker/") { return "Docker" } + return path + } + private nonisolated static func commonSockets(home: String) -> [String] { [ "/var/run/docker.sock", @@ -191,7 +227,7 @@ struct DockerEngineRuntime: ContainerRuntime { private var http: UnixSocketHTTP { UnixSocketHTTP(path: socketPath) } private var decoder: JSONDecoder { JSONDecoder() } - private static let detectionProbeTimeout: TimeInterval = 0.75 + private nonisolated static let detectionProbeTimeout: TimeInterval = 0.75 private nonisolated static let statsProbeTimeout: TimeInterval = 2.5 nonisolated static let maxConcurrentStatsRequests = 8 diff --git a/Dory/Runtime/HostTools.swift b/Dory/Runtime/HostTools.swift new file mode 100644 index 0000000..4b6703b --- /dev/null +++ b/Dory/Runtime/HostTools.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Resolves host-side CLI tools (kubectl, docker) that Dory shells out to. Prefers a copy bundled +/// inside the app so a fresh download needs nothing installed; falls back to a system install for +/// development builds. Everything Dory's engine and GUI do runs through the in-process Docker +/// client — these tools are only for the Kubernetes shell-out and the optional docker-CLI context. +enum HostTools { + static func kubectl() -> String? { resolve("kubectl", systemCandidates: [ + "/usr/local/bin/kubectl", "/opt/homebrew/bin/kubectl", + ]) } + + static func docker() -> String? { resolve("docker", systemCandidates: [ + "/opt/homebrew/bin/docker", "/usr/local/bin/docker", "/usr/bin/docker", + ]) } + + private static func resolve(_ name: String, systemCandidates: [String]) -> String? { + if let bundled = bundledPath(named: name) { return bundled } + return Shell.find(name, candidates: systemCandidates) + } + + private static func bundledPath(named name: String) -> String? { + if let auxiliary = Bundle.main.url(forAuxiliaryExecutable: name)?.path, + FileManager.default.isExecutableFile(atPath: auxiliary) { + return auxiliary + } + let bundleURL = Bundle.main.bundleURL + let candidates = [ + bundleURL.appendingPathComponent("Contents/Helpers/\(name)").path, + bundleURL.appendingPathComponent("Helpers/\(name)").path, + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } +} diff --git a/Dory/Runtime/Kubernetes/KubeClient.swift b/Dory/Runtime/Kubernetes/KubeClient.swift index d87c298..3cd3a7d 100644 --- a/Dory/Runtime/Kubernetes/KubeClient.swift +++ b/Dory/Runtime/Kubernetes/KubeClient.swift @@ -8,7 +8,7 @@ enum KubeError: Error, Sendable, Equatable { struct KubeClient: Sendable { var kubectlPath: String? { - Shell.find("kubectl", candidates: ["/usr/local/bin/kubectl", "/opt/homebrew/bin/kubectl"]) + HostTools.kubectl() } static func kubeconfig() -> String? { diff --git a/Dory/Runtime/Kubernetes/KubeServiceProxy.swift b/Dory/Runtime/Kubernetes/KubeServiceProxy.swift index 2054a61..b6ca30f 100644 --- a/Dory/Runtime/Kubernetes/KubeServiceProxy.swift +++ b/Dory/Runtime/Kubernetes/KubeServiceProxy.swift @@ -16,7 +16,7 @@ enum KubeServiceProxy { static var kubeconfig: String { KubernetesProvisioner.kubeconfigPath } static func kubectl() -> String? { - Shell.find("kubectl", candidates: ["/usr/local/bin/kubectl", "/opt/homebrew/bin/kubectl"]) + HostTools.kubectl() } static func startProxy() -> Process? { diff --git a/Dory/Runtime/Kubernetes/KubernetesProvider.swift b/Dory/Runtime/Kubernetes/KubernetesProvider.swift index 65e961a..a4d146f 100644 --- a/Dory/Runtime/Kubernetes/KubernetesProvider.swift +++ b/Dory/Runtime/Kubernetes/KubernetesProvider.swift @@ -63,7 +63,7 @@ struct KubernetesStatus: Sendable { /// separately (scripts/enable-kubernetes.sh) because it boots infrastructure. struct KubernetesProvider: Sendable { var kubectlPath: String? { - Shell.find("kubectl", candidates: ["/usr/local/bin/kubectl", "/opt/homebrew/bin/kubectl"]) + HostTools.kubectl() } /// Prefer Dory's own cluster kubeconfig when present, so the GUI reflects the cluster Dory diff --git a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift index 88d77f2..9af51dd 100644 --- a/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift +++ b/Dory/Runtime/Kubernetes/KubernetesProvisioner.swift @@ -2,11 +2,14 @@ import Foundation /// One-click Kubernetes: runs a k3s server as a container inside Dory's shared VM (the k3d pattern), /// publishes the API on :6443 (auto-forwarded to `localhost` by the port forwarder), and writes a -/// kubeconfig the host `kubectl` picks up — mirroring OrbStack's built-in cluster. Built images in -/// the shared engine are immediately usable in Pods, with no local registry push. +/// kubeconfig the host `kubectl` picks up — mirroring OrbStack's built-in cluster. NOTE: k3s brings +/// its own embedded containerd image store, SEPARATE from the shared engine's dockerd store. A +/// locally-built Docker image is therefore NOT automatically visible to Pods — push it to a registry +/// the cluster can reach, or import it into k3s's containerd (`k8s.io` namespace). Auto image-sync is +/// a tracked follow-up. enum KubernetesProvisioner { static let containerName = "dory-k8s" - static let defaultImage = KubeVersionCatalog.latest.image + nonisolated static let defaultImage = KubeVersionCatalog.latest.image static let apiPort = 6443 static var kubeconfigPath: String { "\(NSHomeDirectory())/.kube/dory-config" } diff --git a/Dory/Runtime/Machines/DevRecipe.swift b/Dory/Runtime/Machines/DevRecipe.swift index 921d96c..8f2654e 100644 --- a/Dory/Runtime/Machines/DevRecipe.swift +++ b/Dory/Runtime/Machines/DevRecipe.swift @@ -1,12 +1,92 @@ import Foundation -struct DevRecipe: Identifiable, Hashable, Sendable { +struct DevRecipe: Identifiable, Hashable, Sendable, Codable { let id: String let display: String let icon: String let install: String + var summary: String + var distro: String + var arch: String + var resources: Resources + var packages: [String] + var runcmd: [String] + var mounts: [String] + var ports: [Int] + var env: [String: String] + var ssh: SSH + var docker: Bool + var user: User - static let all: [DevRecipe] = [ + struct Resources: Hashable, Sendable, Codable { + var cpus: Int + var memory: String + var disk: String + } + + struct SSH: Hashable, Sendable, Codable { + var agentForward: Bool + } + + struct User: Hashable, Sendable, Codable { + var name: String + var sudo: Bool + var shell: String + } + + enum RecipeError: Error, Equatable, CustomStringConvertible { + case rootNotMapping + case unknownKeys([String]) + case missing(String) + case invalid(String) + + var description: String { + switch self { + case .rootNotMapping: "recipe root must be a mapping" + case .unknownKeys(let keys): "unknown recipe keys: \(keys.joined(separator: ", "))" + case .missing(let key): "missing required recipe key: \(key)" + case .invalid(let message): message + } + } + } + + nonisolated init( + id: String, + display: String, + icon: String, + install: String, + summary: String = "", + distro: String = "ubuntu:24.04", + arch: String = "arm64", + resources: Resources = Resources(cpus: 2, memory: "4GiB", disk: "40GiB"), + packages: [String] = [], + runcmd: [String] = [], + mounts: [String] = [], + ports: [Int] = [], + env: [String: String] = [:], + ssh: SSH = SSH(agentForward: false), + docker: Bool = false, + user: User = User(name: "{{host_user}}", sudo: true, shell: "/bin/bash") + ) { + self.id = id + self.display = display + self.icon = icon + self.install = install + self.summary = summary + self.distro = distro + self.arch = arch + self.resources = resources + self.packages = packages + self.runcmd = runcmd + self.mounts = mounts + self.ports = ports + self.env = env + self.ssh = ssh + self.docker = docker + self.user = user + } + + nonisolated static let all: [DevRecipe] = [ DevRecipe(id: "node", display: "Node.js", icon: "hexagon", install: "curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - && apt-get install -y nodejs && corepack enable"), DevRecipe(id: "python", display: "Python", icon: "chevron.left.forwardslash.chevron.right", @@ -23,5 +103,177 @@ struct DevRecipe: Identifiable, Hashable, Sendable { install: "apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && ARCH=$(dpkg --print-architecture) && DARCH=$([ \"$ARCH\" = arm64 ] && echo aarch64 || echo x86_64) && curl -fsSL https://download.docker.com/linux/static/stable/$DARCH/docker-27.5.1.tgz | tar -xz -C /tmp && install -m0755 /tmp/docker/docker /usr/local/bin/docker && curl -fsSL -o /usr/local/bin/kubectl https://dl.k8s.io/release/$(curl -fsSL https://dl.k8s.io/release/stable.txt)/bin/linux/$ARCH/kubectl && chmod +x /usr/local/bin/kubectl && rm -rf /var/lib/apt/lists/* /tmp/docker"), ] - static func forID(_ id: String) -> DevRecipe? { all.first { $0.id == id } } + nonisolated static func forID(_ id: String) -> DevRecipe? { all.first { $0.id == id } } + + nonisolated static func load(from url: URL) throws -> DevRecipe { + let yaml = try String(contentsOf: url, encoding: .utf8) + let root = try YAMLParser.parse(yaml) + guard let map = root.mappingValue else { throw RecipeError.rootNotMapping } + let allowed: Set = [ + "name", "summary", "distro", "arch", "resources", "packages", "runcmd", + "mounts", "ports", "env", "ssh", "docker", "user", + ] + let unknown = map.keys.filter { !allowed.contains($0) }.sorted() + guard unknown.isEmpty else { throw RecipeError.unknownKeys(unknown) } + guard let name = map["name"]?.stringValue, !name.isEmpty else { throw RecipeError.missing("name") } + guard let distro = map["distro"]?.stringValue, !distro.isEmpty else { throw RecipeError.missing("distro") } + + let resourcesMap = map["resources"]?.mappingValue ?? [:] + let resources = Resources( + cpus: resourcesMap["cpus"]?.intValue ?? 2, + memory: resourcesMap["memory"]?.stringValue ?? "4GiB", + disk: resourcesMap["disk"]?.stringValue ?? "40GiB" + ) + let sshMap = map["ssh"]?.mappingValue ?? [:] + let userMap = map["user"]?.mappingValue ?? [:] + let recipe = DevRecipe( + id: name, + display: name, + icon: "terminal", + install: (map["runcmd"]?.stringList ?? []).joined(separator: " && "), + summary: map["summary"]?.stringValue ?? "", + distro: distro, + arch: map["arch"]?.stringValue ?? "arm64", + resources: resources, + packages: map["packages"]?.stringList ?? [], + runcmd: map["runcmd"]?.stringList ?? [], + mounts: map["mounts"]?.stringList ?? [], + ports: map["ports"]?.intList ?? [], + env: map["env"]?.stringMap ?? [:], + ssh: SSH(agentForward: sshMap["agent_forward"]?.boolValue ?? false), + docker: map["docker"]?.boolValue ?? false, + user: User( + name: userMap["name"]?.stringValue ?? "{{host_user}}", + sudo: userMap["sudo"]?.boolValue ?? true, + shell: userMap["shell"]?.stringValue ?? "/bin/bash" + ) + ) + try recipe.validate() + return recipe + } + + nonisolated func validate() throws { + guard !id.isEmpty else { throw RecipeError.missing("name") } + guard Self.isDockerNameComponent(id) else { + throw RecipeError.invalid("name must be a valid image name component: lowercase letters, digits, and separators (. _ -), no spaces, no uppercase, no / or :") + } + let parts = distro.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2, ["ubuntu", "debian", "fedora", "alpine", "arch"].contains(parts[0]), !parts[1].isEmpty else { + throw RecipeError.invalid("distro must be one of ubuntu|debian|fedora|alpine|arch with a tag") + } + guard ["arm64", "amd64"].contains(arch) else { + throw RecipeError.invalid("arch must be arm64 or amd64") + } + guard resources.cpus > 0 else { throw RecipeError.invalid("resources.cpus must be positive") } + guard Self.isSizeString(resources.memory) else { throw RecipeError.invalid("resources.memory must be a size like 8GiB") } + guard Self.isSizeString(resources.disk) else { throw RecipeError.invalid("resources.disk must be a size like 60GiB") } + try validateTemplates(in: [summary, distro, arch, resources.memory, resources.disk, user.name, user.shell] + packages + runcmd + mounts + env.flatMap { [$0.key, $0.value] }) + } + + nonisolated func substituted(hostUser: String) -> DevRecipe { + let guestUser = user.name.replacingOccurrences(of: "{{host_user}}", with: hostUser) + func sub(_ value: String) -> String { + value + .replacingOccurrences(of: "{{host_user}}", with: hostUser) + .replacingOccurrences(of: "{{user}}", with: guestUser) + } + var copy = self + copy.summary = sub(summary) + copy.distro = sub(distro) + copy.arch = sub(arch) + copy.resources = Resources(cpus: resources.cpus, memory: sub(resources.memory), disk: sub(resources.disk)) + copy.packages = packages.map(sub) + copy.runcmd = runcmd.map(sub) + copy.mounts = mounts.map(sub) + copy.env = Dictionary(uniqueKeysWithValues: env.map { (sub($0.key), sub($0.value)) }) + copy.user = User(name: sub(user.name), sudo: user.sudo, shell: sub(user.shell)) + return copy + } + + nonisolated func provisionScript(packageManager: MachineDistro.PackageManager) -> String { + var lines = [String]() + if !packages.isEmpty { + lines.append(Self.packageInstall(packages, packageManager: packageManager)) + } + let commands = runcmd.isEmpty ? (install.isEmpty ? [] : [install]) : runcmd + lines.append(contentsOf: commands) + if docker { + lines.append("install -d -m 755 /var/run/dory") + } + return lines.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + .joined(separator: "\n") + } + + private nonisolated static func packageInstall(_ packages: [String], packageManager: MachineDistro.PackageManager) -> String { + let names = packages.map(shellQuote).joined(separator: " ") + switch packageManager { + case .apt: + return "apt-get update -qq && apt-get install -y --no-install-recommends \(names) && rm -rf /var/lib/apt/lists/*" + case .dnf: + return "dnf install -y \(names) && dnf clean all" + case .zypper: + return "zypper --non-interactive --gpg-auto-import-keys refresh && zypper -n install \(names) && zypper clean -a" + case .apk: + return "apk add --no-cache \(names)" + case .pacman: + return "pacman -Sy --noconfirm --needed \(names)" + } + } + + private nonisolated static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private nonisolated static func isSizeString(_ value: String) -> Bool { + let pattern = #"^[1-9][0-9]*(MiB|GiB|TiB)$"# + return value.range(of: pattern, options: .regularExpression) != nil + } + + private nonisolated static func isDockerNameComponent(_ value: String) -> Bool { + let pattern = #"^[a-z0-9]+((\.|_|__|-+)[a-z0-9]+)*$"# + return value.range(of: pattern, options: .regularExpression) != nil + } + + private nonisolated func validateTemplates(in values: [String]) throws { + for value in values { + var search = value.startIndex.. String { "dory-recipe/\(recipe.id)-\(arch.rawValue)" } static func recipeDockerfile(baseImageTag: String, recipe: DevRecipe) -> String { + recipeDockerfile(baseImageTag: baseImageTag, recipe: recipe, packageManager: .apt) + } + + static func recipeDockerfile(baseImageTag: String, recipe: DevRecipe, packageManager: MachineDistro.PackageManager) -> String { """ FROM \(baseImageTag) - RUN \(recipe.install) + RUN <<'DORY_RECIPE' + set -e + \(recipe.provisionScript(packageManager: packageManager)) + DORY_RECIPE """ } @@ -104,7 +111,8 @@ enum MachineImageBuilder { let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("dory-recipe-\(recipe.id)-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: dir) } - try recipeDockerfile(baseImageTag: baseTag, recipe: recipe).write(to: dir.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8) + try recipeDockerfile(baseImageTag: baseTag, recipe: recipe, packageManager: distro.pkg) + .write(to: dir.appendingPathComponent("Dockerfile"), atomically: true, encoding: .utf8) guard let tar = AppStore.tarDirectory(dir) else { throw MachineError.imageBuildFailed("Could not package recipe context") } let encodedTag = DockerImageOps.queryValue(tag) let encodedPlatform = DockerImageOps.queryValue(arch.platform) diff --git a/Dory/Runtime/Machines/MachineProvisioner.swift b/Dory/Runtime/Machines/MachineProvisioner.swift index fe3e682..3f71971 100644 --- a/Dory/Runtime/Machines/MachineProvisioner.swift +++ b/Dory/Runtime/Machines/MachineProvisioner.swift @@ -27,6 +27,8 @@ enum MachineProvisioner { } let shim = (["set +e"] + DoryOpenShim.installCommands()).joined(separator: "\n") lines.append("(\n\(shim)\n) || true") + let credentials = (["set +e"] + DoryCredentialShim.installCommands()).joined(separator: "\n") + lines.append("(\n\(credentials)\n) || true") return lines.joined(separator: "\n") } diff --git a/Dory/Runtime/Machines/MachineService.swift b/Dory/Runtime/Machines/MachineService.swift index bf0f6f4..456c184 100644 --- a/Dory/Runtime/Machines/MachineService.swift +++ b/Dory/Runtime/Machines/MachineService.swift @@ -15,7 +15,7 @@ nonisolated struct MachineSettings: Sendable, Hashable { struct MachineService: Sendable { let runtime: any ContainerRuntime - static let namePrefix = "dory-machine-" + nonisolated static let namePrefix = "dory-machine-" static let label = "dory.machine" static let versionLabel = "dory.machine.version" static let archLabel = "dory.machine.arch" @@ -28,12 +28,57 @@ struct MachineService: Sendable { static let keepalive = ["tail", "-f", "/dev/null"] static let snapshotRepoPrefix = "dory-snapshot/" - static func containerName(for name: String) -> String { namePrefix + name } + nonisolated static func containerName(for name: String) -> String { namePrefix + name } static func bridgeHostDir(for name: String) -> String { URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".dory/bridge").appendingPathComponent(name).path } + static func distro(for recipe: DevRecipe) throws -> MachineDistro { + guard let distro = MachineDistro.forImage(recipe.distro) else { + throw MachineError.createFailed("unsupported recipe distro \(recipe.distro)") + } + return distro + } + + static func arch(for recipe: DevRecipe) throws -> MachineArch { + guard let arch = MachineArch(rawValue: recipe.arch) else { + throw MachineError.createFailed("unsupported recipe arch \(recipe.arch)") + } + return arch + } + + static func settings( + from recipe: DevRecipe, + hostUser: String = NSUserName(), + uid: Int = Int(getuid()), + homePath: String = NSHomeDirectory(), + publicKeys: [String] = [] + ) throws -> MachineSettings { + let recipe = recipe.substituted(hostUser: hostUser) + let guestHome = recipe.user.name == "root" + ? "/root" + : (recipe.user.name == hostUser ? homePath : "/home/\(recipe.user.name)") + var settings = MachineSettings( + cpus: recipe.resources.cpus, + memoryMB: memoryMB(from: recipe.resources.memory), + mounts: try recipe.mounts.map { try parseMount($0, hostHome: homePath, guestHome: guestHome) }, + ports: recipe.ports.map { PortPair(host: $0, guest: $0) }, + identity: nil, + env: recipe.env + ) + if recipe.user.name != "root" { + settings.identity = MacIdentity( + username: recipe.user.name, + uid: recipe.user.name == hostUser ? uid : 1000, + homePath: guestHome, + shell: recipe.user.shell, + publicKeys: publicKeys + ) + } + return settings + } + func snapshot(machine: Machine, note: String, createdISO: String, tag: String) async throws -> MachineSnapshot { let labels = SnapshotLabels.make(machine: machine, note: note, createdISO: createdISO) let repo = Self.snapshotRepoPrefix + machine.name @@ -77,6 +122,7 @@ struct MachineService: Sendable { "Privileged": true, "CgroupnsMode": "host", "Tmpfs": ["/run": "", "/run/lock": "", "/tmp": ""], + "ExtraHosts": ["host.docker.internal:host-gateway", "host.dory.internal:host-gateway"], "RestartPolicy": ["Name": "unless-stopped"], ] var hostConfig = self.hostConfig(base: baseHostConfig, settings: settings) @@ -88,7 +134,7 @@ struct MachineService: Sendable { "Hostname": name, "Image": imageTag, "Cmd": cmd, - "Env": (["container=docker", "BROWSER=dory-open"] + settings.env.map { "\($0.key)=\($0.value)" }).sorted(), + "Env": Self.machineEnv(settings: settings), "StopSignal": "SIGRTMIN+3", "Labels": labels, "HostConfig": hostConfig, @@ -196,6 +242,18 @@ struct MachineService: Sendable { progress("Machine \(name) is ready.") } + func create(name: String, recipe: DevRecipe, progress: @escaping @Sendable (String) -> Void) async throws { + let recipe = recipe.substituted(hostUser: NSUserName()) + try recipe.validate() + let distro = try Self.distro(for: recipe) + let arch = try Self.arch(for: recipe) + let settings = try Self.settings( + from: recipe, + publicKeys: MacIdentity.current(shell: recipe.user.shell).publicKeys + ) + try await create(name: name, distro: distro, arch: arch, recipe: recipe, settings: settings, progress: progress) + } + func start(name: String) async throws { try await runtime.start(containerID: Self.containerName(for: name)) _ = try? await runtime.exec(containerID: Self.containerName(for: name), @@ -263,7 +321,7 @@ struct MachineService: Sendable { "Hostname": name, "Image": imageRef, "Cmd": cmd, - "Env": (["container=docker", "BROWSER=dory-open"] + effectiveSettings.env.map { "\($0.key)=\($0.value)" }).sorted(), + "Env": Self.machineEnv(settings: effectiveSettings), "StopSignal": "SIGRTMIN+3", "Labels": labels, "HostConfig": hostConfig, @@ -492,6 +550,44 @@ extension MachineService { return MountPair(host: parts[0], guest: parts[1], readOnly: parts.count == 3 && parts[2] == "ro") } + nonisolated static func parseMount(_ value: String, hostHome: String = NSHomeDirectory(), guestHome: String) throws -> MountPair { + guard let mount = parseBind(value) else { + throw MachineError.createFailed("invalid recipe mount \(value)") + } + let host = try expandTilde(mount.host, home: hostHome, mount: value) + let guest = try expandTilde(mount.guest, home: guestHome, mount: value) + return MountPair(host: host, guest: guest, readOnly: mount.readOnly) + } + + nonisolated static func expandTilde(_ path: String, home: String, mount: String) throws -> String { + guard !path.isEmpty else { + throw MachineError.createFailed("invalid recipe mount \(mount)") + } + guard path.hasPrefix("~") else { return path } + let trimmedHome = home.hasSuffix("/") ? String(home.dropLast()) : home + guard !trimmedHome.isEmpty else { + throw MachineError.createFailed("cannot resolve home directory for recipe mount \(mount)") + } + if path == "~" { return trimmedHome } + guard path.hasPrefix("~/") else { + throw MachineError.createFailed("unsupported ~user expansion in recipe mount \(mount)") + } + return trimmedHome + String(path.dropFirst(1)) + } + + nonisolated static func memoryMB(from size: String) -> Int? { + let units: [(suffix: String, multiplier: Int)] = [ + ("TiB", 1024 * 1024), + ("GiB", 1024), + ("MiB", 1), + ] + for unit in units where size.hasSuffix(unit.suffix) { + let raw = String(size.dropLast(unit.suffix.count)) + return Int(raw).map { $0 * unit.multiplier } + } + return nil + } + nonisolated static func uniqueMounts(_ mounts: [MountPair]) -> [MountPair] { var seen = Set() return mounts.filter { seen.insert($0).inserted } @@ -520,4 +616,14 @@ extension MachineService { for port in settings.ports { exposed["\(port.guest)/tcp"] = [:] } return exposed } + + static func machineEnv(settings: MachineSettings) -> [String] { + let credentialsDir = "\(DoryCredentialShim.bridgeGuestDir)/credentials" + let credentialEnv = [ + "SSH_AUTH_SOCK=\(credentialsDir)/ssh-agent.sock", + "GIT_ASKPASS=\(DoryCredentialShim.gitAskpassPath)", + "DORY_GIT_ASKPASS_SOCK=\(credentialsDir)/git-askpass.sock", + ] + return (["container=docker", "BROWSER=dory-open"] + credentialEnv + settings.env.map { "\($0.key)=\($0.value)" }).sorted() + } } diff --git a/Dory/Runtime/Machines/MachineSnapshot.swift b/Dory/Runtime/Machines/MachineSnapshot.swift index 89c68dc..8ee7764 100644 --- a/Dory/Runtime/Machines/MachineSnapshot.swift +++ b/Dory/Runtime/Machines/MachineSnapshot.swift @@ -39,6 +39,105 @@ struct MachineSnapshot: Identifiable, Hashable, Sendable { } } +enum SnapshotScheduleFrequency: String, Codable, CaseIterable, Sendable { + case hourly, daily, weekly + + var interval: TimeInterval { + switch self { + case .hourly: 60 * 60 + case .daily: 24 * 60 * 60 + case .weekly: 7 * 24 * 60 * 60 + } + } +} + +struct MachineSnapshotSchedule: Codable, Equatable, Sendable { + var machineName: String + var frequency: SnapshotScheduleFrequency + var keepLocal: Int + var s3: S3BackupDestination? + + init(machineName: String, frequency: SnapshotScheduleFrequency, keepLocal: Int = 7, s3: S3BackupDestination? = nil) { + self.machineName = machineName + self.frequency = frequency + self.keepLocal = keepLocal + self.s3 = s3 + } + + func validate() throws { + guard !machineName.isEmpty, machineName.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" }) else { + throw SnapshotScheduleError.invalidMachineName + } + guard keepLocal > 0 else { throw SnapshotScheduleError.invalidRetention } + try s3?.validate() + } + + func isDue(lastSnapshotAt: Date?, now: Date) -> Bool { + guard let lastSnapshotAt else { return true } + return now.timeIntervalSince(lastSnapshotAt) >= frequency.interval + } + + func nextRun(after lastSnapshotAt: Date?) -> Date? { + guard let lastSnapshotAt else { return nil } + return lastSnapshotAt.addingTimeInterval(frequency.interval) + } +} + +struct S3BackupDestination: Codable, Equatable, Sendable { + var bucket: String + var prefix: String + var region: String? + + init(bucket: String, prefix: String = "", region: String? = nil) { + self.bucket = bucket + self.prefix = prefix.trimmingCharacters(in: CharacterSet(charactersIn: "/")) + self.region = region + } + + func validate() throws { + guard Self.isValidBucket(bucket) else { throw SnapshotScheduleError.invalidBucket } + guard !prefix.contains("..") else { throw SnapshotScheduleError.invalidPrefix } + } + + func objectKey(for snapshot: MachineSnapshot) -> String { + let timestamp = snapshot.createdISO + .replacingOccurrences(of: ":", with: "") + .replacingOccurrences(of: "-", with: "") + let filename = "\(snapshot.machineName)-\(timestamp)-\(snapshot.id.safeSnapshotComponent).tar" + return prefix.isEmpty ? filename : "\(prefix)/\(filename)" + } + + func url(for snapshot: MachineSnapshot) -> String { + "s3://\(bucket)/\(objectKey(for: snapshot))" + } + + static func isValidBucket(_ value: String) -> Bool { + guard (3...63).contains(value.count), + value.first?.isLetter == true || value.first?.isNumber == true, + value.last?.isLetter == true || value.last?.isNumber == true else { + return false + } + return value.allSatisfy { $0.isLowercase || $0.isNumber || $0 == "." || $0 == "-" } + } +} + +enum SnapshotScheduleError: Error, Equatable { + case invalidMachineName + case invalidRetention + case invalidBucket + case invalidPrefix +} + +private extension String { + var safeSnapshotComponent: String { + let mapped = map { character -> Character in + character.isLetter || character.isNumber || character == "-" || character == "_" ? character : "-" + } + let value = String(mapped).trimmingCharacters(in: CharacterSet(charactersIn: "-")) + return value.isEmpty ? "snapshot" : value + } +} + enum SnapshotLabels { static let ofKey = "dory.snapshot.of" static let noteKey = "dory.snapshot.note" diff --git a/Dory/Runtime/Machines/RecipeStore.swift b/Dory/Runtime/Machines/RecipeStore.swift new file mode 100644 index 0000000..0b64539 --- /dev/null +++ b/Dory/Runtime/Machines/RecipeStore.swift @@ -0,0 +1,45 @@ +import Foundation + +struct RecipeStore: Sendable { + let userDirectory: URL + let builtInDirectory: URL? + + init( + userDirectory: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".dory") + .appendingPathComponent("recipes"), + builtInDirectory: URL? = Bundle.main.resourceURL?.appendingPathComponent("Recipes") + ) { + self.userDirectory = userDirectory + self.builtInDirectory = builtInDirectory + } + + func loadAll() throws -> [DevRecipe] { + var recipes = [DevRecipe]() + if let builtInDirectory { + recipes.append(contentsOf: try loadRecipes(in: builtInDirectory, missingIsEmpty: true)) + } + recipes.append(contentsOf: try loadRecipes(in: userDirectory, missingIsEmpty: true)) + return recipes.sorted { $0.id < $1.id } + } + + func load(named nameOrPath: String) throws -> DevRecipe? { + let explicit = URL(fileURLWithPath: NSString(string: nameOrPath).expandingTildeInPath) + if FileManager.default.fileExists(atPath: explicit.path) { + return try DevRecipe.load(from: explicit) + } + return try loadAll().first { $0.id == nameOrPath } + } + + private func loadRecipes(in directory: URL, missingIsEmpty: Bool) throws -> [DevRecipe] { + guard FileManager.default.fileExists(atPath: directory.path) else { + if missingIsEmpty { return [] } + throw CocoaError(.fileNoSuchFile) + } + let urls = try FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).filter { ["yaml", "yml"].contains($0.pathExtension.lowercased()) } + return try urls.map(DevRecipe.load(from:)) + } +} diff --git a/Dory/Runtime/Machines/SSHConfigWriter.swift b/Dory/Runtime/Machines/SSHConfigWriter.swift new file mode 100644 index 0000000..6fb05ed --- /dev/null +++ b/Dory/Runtime/Machines/SSHConfigWriter.swift @@ -0,0 +1,77 @@ +import Foundation + +struct SSHConfigWriter: Sendable { + var configURL: URL + + init(configURL: URL = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".dory") + .appendingPathComponent("ssh") + .appendingPathComponent("config")) { + self.configURL = configURL + } + + nonisolated static func hostBlock(for machine: Machine) -> String { + let host = shellSafeHost(machine.name) + let user = machine.username.isEmpty ? "root" : machine.username + var lines = [ + "Host \(host)", + " HostName 127.0.0.1", + " User \(user)", + " StrictHostKeyChecking accept-new", + ] + if let port = machine.sshPort { + lines.append(" Port \(port)") + } else { + let container = MachineService.containerName(for: machine.name) + let command = [ + "docker", + "-H", + "unix://$HOME/.dory/dory.sock", + "exec", + "-i", + shellQuote(container), + "sh", + "-lc", + shellQuote("exec nc 127.0.0.1 22"), + ].joined(separator: " ") + lines.append(" ProxyCommand \(command)") + } + return lines.joined(separator: "\n") + "\n" + } + + nonisolated static func config(for machines: [Machine]) -> String { + machines + .sorted { $0.name < $1.name } + .map(hostBlock(for:)) + .joined(separator: "\n") + } + + func write(machines: [Machine]) throws { + try FileManager.default.createDirectory( + at: configURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Self.config(for: machines).write(to: configURL, atomically: true, encoding: .utf8) + } + + nonisolated static var includeInstruction: String { + "Include ~/.dory/ssh/config" + } + + private nonisolated static func shellSafeHost(_ value: String) -> String { + value + .lowercased() + .map { character in + character.isLetter || character.isNumber || character == "-" ? character : "-" + } + .reduce(into: "") { result, character in + if character == "-", result.last == "-" { return } + result.append(character) + } + .trimmingCharacters(in: CharacterSet(charactersIn: "-")) + } + + private nonisolated static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } +} diff --git a/Dory/Runtime/Shared/SharedVMProvisioner.swift b/Dory/Runtime/Shared/SharedVMProvisioner.swift index a08c24d..9c33c0d 100644 --- a/Dory/Runtime/Shared/SharedVMProvisioner.swift +++ b/Dory/Runtime/Shared/SharedVMProvisioner.swift @@ -1,163 +1,310 @@ +import Darwin import Foundation -/// Brings up a single shared Linux VM that hosts a Docker engine for ALL of Dory's workloads, -/// the way OrbStack and Docker Desktop do — instead of Apple `container`'s one-VM-per-container -/// model. One persistent micro-VM (via Apple's `container` engine) runs `dockerd`; its socket is -/// published to the host; Dory's existing, fully-verified `DockerEngineRuntime` drives it. Every -/// `docker run` then lands inside the one shared VM, sharing a single kernel and memory pool. +/// Brings up Dory's single shared Linux VM — `dory-hv`, our own VMM on Hypervisor.framework — which +/// hosts one Docker engine for ALL of Dory's workloads, the way OrbStack does. This is the sole +/// engine: it ships its own kernel, userspace networking (gvproxy), and a journaled data disk, so +/// it needs no Apple `container` toolchain and gives every user the same performance. Dory's Docker +/// runtime then drives the published socket. enum SharedVMProvisioner { - static let engineName = "dory-engine" - static let dataVolume = "dory-engine-data" - static let image = "docker.io/library/docker:dind" - static let versionLabel = "dory.engine.spec" - /// Bump when the engine's `container run` spec changes (mounts, flags) so existing engines are - /// recreated on the next launch. Persistent images survive via the data volume. - static let engineSpecVersion = "v2-homeshare" static var socketPath: String { "\(NSHomeDirectory())/.dory/engine.sock" } + static var engineIPPath: String { "\(NSHomeDirectory())/.dory/engine.ip" } - private static let binaryCandidates = ["/opt/homebrew/bin/container", "/usr/local/bin/container"] + private static let zstdCandidates = ["/opt/homebrew/bin/zstd", "/usr/local/bin/zstd", "/usr/bin/zstd"] + nonisolated private static let helperPIDPath = "\(NSHomeDirectory())/.dory/engine.pid" + nonisolated private static let helperLogPath = "\(NSHomeDirectory())/.dory/engine.log" + nonisolated static let defaultEngineMemoryMB = 2048 + nonisolated static let defaultEngineHeadroomMB = 512 struct Config: Sendable { var cpus: Int - /// Guest RAM ceiling. Apple's Virtualization.framework backs guest pages lazily, so a - /// generous cap costs nothing until workloads actually use it. + /// Guest RAM ceiling. The engine reclaims below the ceiling via free page reporting, so a + /// generous cap costs nothing until workloads actually use it; env vars can raise it. var memory: String + var headroomMB: Int + /// Opt-in x86/amd64 via Rosetta: runs the Virtualization.framework engine (which supports + /// Rosetta) instead of dory-hv, so heavy amd64 images like SQL Server run reliably (proven). + /// Trades away dory-hv's memory advantage while on, so it is a manual Settings toggle. + var rosettaX86: Bool - nonisolated init(cpus: Int = 4, memory: String = "4096M") { + static let rosettaX86Key = "dory.rosettaX86Enabled" + static let rosettaEngineMemoryMB = 3072 + + nonisolated init( + cpus: Int = 4, + memory: String = "\(SharedVMProvisioner.defaultEngineMemoryMB)M", + headroomMB: Int = SharedVMProvisioner.defaultEngineHeadroomMB, + rosettaX86: Bool = UserDefaults.standard.bool(forKey: Config.rosettaX86Key) + ) { self.cpus = cpus self.memory = memory + self.headroomMB = headroomMB + self.rosettaX86 = rosettaX86 + } + + var memoryMB: Int { + SharedVMProvisioner.memoryStringToMB(memory) ?? SharedVMProvisioner.defaultEngineMemoryMB } } enum ProvisionError: Error, Sendable { case unsupportedHost(String) - case containerCLINotFound - case systemUnavailable + case engineUnavailable case engineStartFailed(String) case engineUnreachable } - /// Prefers a `container` toolchain bundled inside the app (so a downloaded Dory.app is fully - /// self-contained) and falls back to a system install. The full toolchain (binaries + Linux - /// kernel + plugins) is copied into `Dory.app/Contents/Helpers/container` by the release - /// pipeline; until then this resolves the Homebrew/system install. - static func containerBinary() -> String? { - // QA hook: simulate a fresh Mac with no toolchain, to exercise the first-run setup flow. - if ProcessInfo.processInfo.environment["DORY_NO_TOOLCHAIN"] == "1" { return nil } - if let helpers = Bundle.main.url(forResource: "container", withExtension: nil, subdirectory: "Helpers")?.path, - FileManager.default.isExecutableFile(atPath: helpers) { - return helpers + static func hostSupport( + platform: MacHostPlatform = .current(), + engineAvailable: Bool = hvEngineAvailable() + ) -> RuntimeSupport { + let base = DoryHVSupport.evaluate(platform: platform) + guard base.isSupported else { return base } + // The hardware is capable, but the engine's own binaries/kernel must be present and the + // user must not have opted out (DORY_HV_ENGINE=0). Otherwise report it honestly so the app + // falls back to a Docker-compatible engine instead of showing a misleading boot failure. + guard engineAvailable else { + return .unsupported("Dory's engine is unavailable on this install", issue: .missingToolchain) } - return Shell.find("container", candidates: binaryCandidates) + return .supported } - static func hostSupport(platform: MacHostPlatform = .current(), containerBinaryPath: String? = containerBinary()) -> RuntimeSupport { - AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: containerBinaryPath != nil) + /// Whether the dory-hv engine can run here: the signed helper, gvproxy, and a resolvable kernel + /// (bundled compressed resource, or an installed kernel) are all present. Default on; set + /// DORY_HV_ENGINE=0 to force-disable for debugging. Synchronous, so host-support can call it. + static func hvEngineAvailable(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + guard environment["DORY_HV_ENGINE"] != "0" else { return false } + guard hvHelperBinary() != nil, gvproxyBinary() != nil else { return false } + if Bundle.main.url(forResource: "dory-vm-kernel", withExtension: "zst") != nil { return true } + return installedKernelPath() != nil } - /// Path to the engine image (`docker:dind`) tar bundled in the app's Resources, if present. - /// When bundled, the engine is loaded offline — no Docker Hub round-trip on first launch. - static func bundledImageTar() -> String? { - for ext in ["tar", "tar.gz"] { - if let url = Bundle.main.url(forResource: "dory-engine-image", withExtension: ext), - FileManager.default.fileExists(atPath: url.path) { - return url.path + static func provision(config: Config = Config()) async throws -> String { + let support = hostSupport() + guard support.isSupported else { + throw ProvisionError.unsupportedHost(support.reason) + } + if config.rosettaX86 { + guard let socket = try await provisionWithRosettaEngine(config: config) else { + throw ProvisionError.engineUnavailable } + return socket + } + guard let socket = try await provisionWithHVEngine(config: config) else { + throw ProvisionError.engineUnavailable } - return nil + return socket } - private static func ensureImage(binary: String) async { - let present = await Shell.runAsyncResult(binary, ["image", "inspect", image]) - if present.exit == 0 { return } - if let tar = bundledImageTar() { - let load = await Shell.runAsyncResult(binary, ["image", "load", "-i", tar]) - if load.exit == 0 { return } + /// The Virtualization.framework engine with Rosetta x86 translation (opt-in). Runs the same + /// `dockerd`-in-VM as dory-hv but on VZ, so `docker run --platform linux/amd64` uses Rosetta — + /// heavy amd64 images like SQL Server run reliably where qemu-user segfaults (proven). Publishes + /// to the same `engine.sock`, so the shim and docker context are unchanged. + private static func provisionWithRosettaEngine(config: Config) async throws -> String? { + guard let helper = vmHelperBinary(), + let kernel = await hvKernelPath(), + let initfs = await vmInitfsPath() else { return nil } + + if await isReachable(), helperProcessIsAlive() { + return socketPath } - _ = await Shell.runAsyncResult(binary, ["image", "pull", image]) - } + stopHelper() - static func provision(config: Config = Config()) async throws -> String { - let binaryPath = containerBinary() - let support = hostSupport(containerBinaryPath: binaryPath) - guard support.isSupported else { - throw ProvisionError.unsupportedHost(support.reason) + let directory = (socketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + try? FileManager.default.removeItem(atPath: socketPath) + + let process = Process() + process.executableURL = URL(fileURLWithPath: helper) + process.arguments = ["--shared-engine", socketPath, "--kernel", kernel, "--initfs", initfs] + var environment = ProcessInfo.processInfo.environment + environment["DORY_ENGINE_ROSETTA"] = "1" + // SQL Server refuses to start below 2 GB; default to 3 GB headroom (reclaimed when idle). + if environment["DORY_ENGINE_MEM_MB"] == nil { + environment["DORY_ENGINE_MEM_MB"] = String(max(Config.rosettaEngineMemoryMB, config.memoryMB)) } - guard let binary = binaryPath else { - throw ProvisionError.containerCLINotFound + process.environment = environment + + FileManager.default.createFile(atPath: helperLogPath, contents: nil) + let log = try? FileHandle(forWritingTo: URL(fileURLWithPath: helperLogPath)) + _ = try? log?.seekToEnd() + log?.write(Data("\n--- starting VZ+Rosetta engine \(Date()) mem=\(environment["DORY_ENGINE_MEM_MB"] ?? "?")MiB ---\n".utf8)) + process.standardOutput = log ?? FileHandle.nullDevice + process.standardError = log ?? FileHandle.nullDevice + + do { + try process.run() + try? "\(process.processIdentifier)\n".write(toFile: helperPIDPath, atomically: true, encoding: .utf8) + try? log?.close() + } catch { + try? log?.close() + throw ProvisionError.engineStartFailed("\(error)") } - let status = await Shell.runAsyncResult(binary, ["system", "status"]) - if status.exit != 0 { - // A fresh toolchain has no Linux kernel yet, and `system start` prompts interactively - // for one — which would hang this non-interactive launch. Opt in explicitly; older - // CLIs without the flag reject it, so fall back to the plain form for them. - var start = await Shell.runAsyncResult(binary, ["system", "start", "--enable-kernel-install"]) - if start.exit != 0 { - start = await Shell.runAsyncResult(binary, ["system", "start"]) - } - guard start.exit == 0 else { throw ProvisionError.systemUnavailable } + guard await waitForReachable(attempts: 240) else { + if process.isRunning { process.terminate() } + throw ProvisionError.engineUnreachable } + return socketPath + } - // Reuse a healthy engine if one is already serving the current spec — but recreate it if it - // predates a spec change (e.g. host file sharing was added), so upgrades take effect. The - // persistent data volume keeps images across the recreate. - if await isReachable(), await engineIsCurrent(binary: binary) { return socketPath } + static func runtime(config: Config = Config()) async -> DockerEngineRuntime? { + guard let socket = try? await provision(config: config) else { return nil } + return DockerEngineRuntime(socketPath: socket, kind: .sharedVM) + } - let directory = (socketPath as NSString).deletingLastPathComponent - try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + /// Dory's own VMM (dory-hv on Hypervisor.framework): elastic memory via free page reporting, + /// SMP, and a persistent journaled data disk. Reuses a live engine; otherwise spawns the helper + /// and waits for the docker socket. + private static func provisionWithHVEngine(config: Config) async throws -> String? { + guard let helper = hvHelperBinary(), let gvproxy = gvproxyBinary() else { return nil } + guard let kernel = await hvKernelPath() else { return nil } - // Restart an existing-but-stopped engine first (keeps the cache warm) unless it's outdated. - if await engineIsCurrent(binary: binary) { - let restart = await Shell.runAsyncResult(binary, ["start", engineName]) - if restart.exit == 0, await waitForReachable() { return socketPath } + if await isReachable(), helperProcessIsAlive() { + return socketPath } + stopHelper() - _ = await Shell.runAsyncResult(binary, ["rm", "-f", engineName]) + let directory = (socketPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) try? FileManager.default.removeItem(atPath: socketPath) - _ = await Shell.runAsyncResult(binary, ["volume", "create", dataVolume]) - await ensureImage(binary: binary) - // Share the user's home directory into the VM at the same path, so host bind mounts - // (`docker run -v ~/project:/app`) resolve transparently — OrbStack's file-sharing model. - let home = NSHomeDirectory() - let run = await Shell.runAsyncResult(binary, [ - "run", "-d", "--name", engineName, - "--cpus", String(config.cpus), "--memory", config.memory, - "--cap-add", "ALL", - "--label", "\(versionLabel)=\(engineSpecVersion)", - "--volume", "\(dataVolume):/var/lib/docker", - "--mount", "type=virtiofs,source=\(home),target=\(home)", - "--publish-socket", "\(socketPath):/var/run/docker.sock", - "-e", "DOCKER_TLS_CERTDIR=", - image, - "dockerd", "--host=unix:///var/run/docker.sock", - ]) - guard run.exit == 0 else { throw ProvisionError.engineStartFailed(run.output) } - guard await waitForReachable() else { throw ProvisionError.engineUnreachable } + var arguments = engineArguments(config: config, kernel: kernel, gvproxy: gvproxy, rootfs: nil) + // Offline builds ship the engine image; hand it to the helper so first launch needs no + // network. Online builds omit it and the engine fetches the image once. + if let rootfs = await hvRootfsPath() { + arguments = engineArguments(config: config, kernel: kernel, gvproxy: gvproxy, rootfs: rootfs) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: helper) + process.arguments = arguments + + FileManager.default.createFile(atPath: helperLogPath, contents: nil) + let log = try? FileHandle(forWritingTo: URL(fileURLWithPath: helperLogPath)) + _ = try? log?.seekToEnd() + log?.write(Data("\n--- starting dory-hv engine \(Date()) mem=\(config.memoryMB)MiB ---\n".utf8)) + process.standardOutput = log ?? FileHandle.nullDevice + process.standardError = log ?? FileHandle.nullDevice + + do { + try process.run() + try? "\(process.processIdentifier)\n".write(toFile: helperPIDPath, atomically: true, encoding: .utf8) + try? log?.close() + } catch { + try? log?.close() + throw ProvisionError.engineStartFailed("\(error)") + } + + guard await waitForReachable(attempts: 240) else { + if process.isRunning { process.terminate() } + throw ProvisionError.engineUnreachable + } return socketPath } - static func runtime(config: Config = Config()) async -> DockerEngineRuntime? { - guard let socket = try? await provision(config: config) else { return nil } - return DockerEngineRuntime(socketPath: socket, kind: .sharedVM) + static func engineArguments(config: Config, kernel: String, gvproxy: String, rootfs: String?) -> [String] { + var arguments = [ + "engine", + "--engine-sock", socketPath, + "--kernel", kernel, + "--gvproxy", gvproxy, + "--mem-mb", String(config.memoryMB), + "--cpus", String(config.cpus), + "--direct-ip", + ] + if let rootfs { + arguments.append(contentsOf: ["--rootfs", rootfs]) + } + // Share the user's home at its identical guest path so `-v ~/project:/app` bind mounts + // resolve with no configuration — the OrbStack "just works" default. Plain virtio-fs (no + // DAX): matches OrbStack on realistic workloads with none of DAX's caveats. `:safe` hides + // credential stores and shell rc files (`~/.ssh`, `~/.aws`, `Library`, …) from every + // container as defense-in-depth; per-bind-mount on-demand sharing is the stronger follow-up. + let home = NSHomeDirectory() + arguments.append(contentsOf: ["--share", "home=\(home):rw:at=\(home):safe"]) + return arguments + } + + private static func hvKernelPath() async -> String? { + if let bundled = await prepareCompressedResource(resource: "dory-vm-kernel", outputName: "dory-vm-kernel") { + return bundled + } + return installedKernelPath() + } + + /// The bundled, decompressed engine rootfs for OFFLINE builds. Online builds omit the resource + /// and this returns nil, so the engine fetches the image once on first launch instead. + private static func hvRootfsPath() async -> String? { + await prepareCompressedResource(resource: "dory-engine-rootfs.ext4", outputName: "dory-engine-rootfs.ext4") } - /// True if an engine container exists with the current spec version (so it can be reused), - /// false if it's absent or predates the current spec (so it must be recreated). - private static func engineIsCurrent(binary: String) async -> Bool { - let result = await Shell.runAsyncResult(binary, ["inspect", engineName]) - return result.exit == 0 && result.output.contains(engineSpecVersion) + private static func hvHelperBinary() -> String? { + let environment = ProcessInfo.processInfo.environment + if let override = environment["DORY_HV_HELPER"], + !override.isEmpty, + FileManager.default.isExecutableFile(atPath: override) { + return override + } + if let helper = bundledHelperPath(named: "dory-hv"), + FileManager.default.isExecutableFile(atPath: helper) { + return helper + } + let cwd = FileManager.default.currentDirectoryPath + let devCandidates = [ + "\(cwd)/Packages/ContainerizationEngine/.build/out/Products/Release/dory-hv", + "\(cwd)/Packages/ContainerizationEngine/.build/out/Products/Debug/dory-hv", + ] + return devCandidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func vmHelperBinary() -> String? { + let environment = ProcessInfo.processInfo.environment + if let override = environment["DORY_VM_HELPER"], + !override.isEmpty, + FileManager.default.isExecutableFile(atPath: override) { + return override + } + if let helper = bundledHelperPath(named: "dory-vm"), + FileManager.default.isExecutableFile(atPath: helper) { + return helper + } + let cwd = FileManager.default.currentDirectoryPath + let devCandidates = [ + "\(cwd)/Packages/ContainerizationEngine/.build/arm64-apple-macosx/release/dory-vmboot", + "\(cwd)/Packages/ContainerizationEngine/.build/arm64-apple-macosx/debug/dory-vmboot", + ] + return devCandidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func vmInitfsPath() async -> String? { + await prepareCompressedResource(resource: "dory-vm-initfs.ext4", outputName: "dory-vm-initfs.ext4") + } + + private static func gvproxyBinary() -> String? { + let environment = ProcessInfo.processInfo.environment + if let override = environment["DORY_GVPROXY"], + !override.isEmpty, + FileManager.default.isExecutableFile(atPath: override) { + return override + } + if let bundled = bundledHelperPath(named: "gvproxy"), + FileManager.default.isExecutableFile(atPath: bundled) { + return bundled + } + let candidates = [ + "/opt/homebrew/opt/podman/libexec/podman/gvproxy", + "/usr/local/opt/podman/libexec/podman/gvproxy", + "/opt/homebrew/bin/gvproxy", + "/usr/local/bin/gvproxy", + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } } - /// Register x86/amd64 emulation in the shared VM so Intel images run on Apple silicon — the - /// way OrbStack does (OrbStack uses Rosetta; this installs the reliable qemu binfmt handler). - /// Idempotent: skips if amd64 is already registered. + /// Register x86/amd64 emulation in the shared VM so Intel images run on Apple silicon, the way + /// OrbStack does. Idempotent: the binfmt installer is a no-op if amd64 is already registered. static func ensureEmulation() async { let runtime = DockerEngineRuntime(socketPath: socketPath, kind: .sharedVM) - if let check = try? await runtime.exec(containerID: engineName, - command: ["sh", "-c", "ls /proc/sys/fs/binfmt_misc/ 2>/dev/null | grep -q qemu-x86_64 && echo ok"]), - check.output.contains("ok") { return } try? await runtime.pull(image: "tonistiigi/binfmt") let body = Data(#"{"Image":"tonistiigi/binfmt","Cmd":["--install","amd64"],"HostConfig":{"Privileged":true,"AutoRemove":true}}"#.utf8) let encodedName = DockerImageOps.queryValue("dory-binfmt") @@ -174,37 +321,28 @@ enum SharedVMProvisioner { } static func stop() async { - guard let binary = containerBinary() else { return } - _ = await Shell.runAsyncResult(binary, ["stop", engineName]) + stopHelper() } - static func stopEngineCommand() -> (binary: String, arguments: [String])? { - guard let binary = containerBinary() else { return nil } - return (binary, ["stop", engineName]) + static func stopEngineDetached() { + stopHelper() } - static func stopEngineDetached() { - guard let command = stopEngineCommand() else { return } - let process = Process() - process.executableURL = URL(fileURLWithPath: command.binary) - process.arguments = command.arguments - process.standardOutput = Pipe() - process.standardError = Pipe() - try? process.run() + @discardableResult + nonisolated static func resyncClockAfterWake( + pid: pid_t? = helperPID(), + isAlive: (pid_t) -> Bool = helperProcessIsAlive(pid:), + signalSender: (pid_t, Int32) -> Int32 = Darwin.kill + ) -> Bool { + guard let pid, pid > 0 else { return false } + guard isAlive(pid) else { return false } + return signalSender(pid, SIGUSR1) == 0 } - /// The shared VM's host-reachable IPv4 address (e.g. 192.168.64.x), used to forward published - /// container ports to `localhost`. + /// The shared VM's host-reachable IPv4 address, written by the engine to `engine.ip`, used to + /// forward published container ports to `localhost`. static func engineIP() async -> String? { - guard let binary = containerBinary() else { return nil } - let result = await Shell.runAsyncResult(binary, ["ls"]) - for line in result.output.split(separator: "\n") where line.contains(engineName) { - for token in line.split(whereSeparator: { $0 == " " || $0 == "\t" }) { - let candidate = token.split(separator: "/").first.map(String.init) ?? String(token) - if isIPv4(candidate) { return candidate } - } - } - return nil + engineIPFromFile() } private static func isIPv4(_ string: String) -> Bool { @@ -227,4 +365,119 @@ enum SharedVMProvisioner { let response = await runtime.proxyRequest(method: "GET", path: "/version", headers: [], body: Data()) return response?.isSuccess ?? false } + + private static func prepareCompressedResource(resource: String, outputName: String) async -> String? { + guard let source = Bundle.main.url(forResource: resource, withExtension: "zst"), + let zstd = zstdBinary() else { return nil } + let directory = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".dory/vm") + let output = directory.appendingPathComponent(outputName) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + if shouldRefreshAsset(source: source, output: output) { + let result = await Shell.runAsyncResult(zstd, ["-d", "-q", "-f", source.path, "-o", output.path]) + guard result.exit == 0 else { return nil } + } + return FileManager.default.fileExists(atPath: output.path) ? output.path : nil + } + + private static func shouldRefreshAsset(source: URL, output: URL) -> Bool { + guard FileManager.default.fileExists(atPath: output.path) else { return true } + let sourceDate = try? source.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let outputDate = try? output.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + guard let sourceDate, let outputDate else { return false } + return outputDate < sourceDate + } + + /// A vmlinux left by a prior Apple `container` install, used only as a dev convenience so the + /// engine boots without the bundled kernel asset. Ships with the compressed kernel bundled. + private static func installedKernelPath() -> String? { + let root = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support/com.apple.container/kernels") + guard let entries = try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else { return nil } + return entries + .filter { $0.lastPathComponent.hasPrefix("vmlinux-") } + .sorted { $0.lastPathComponent < $1.lastPathComponent } + .last? + .path + } + + private static func zstdBinary() -> String? { + if let helper = bundledHelperPath(named: "zstd"), + FileManager.default.isExecutableFile(atPath: helper) { + return helper + } + return Shell.find("zstd", candidates: zstdCandidates) + } + + private static func bundledHelperPath(named name: String) -> String? { + if let auxiliary = Bundle.main.url(forAuxiliaryExecutable: name)?.path { + return auxiliary + } + let bundleURL = Bundle.main.bundleURL + let candidates = [ + bundleURL.appendingPathComponent("Contents/Helpers/\(name)").path, + bundleURL.appendingPathComponent("Helpers/\(name)").path, + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0) } + } + + private static func engineIPFromFile() -> String? { + guard let raw = try? String(contentsOfFile: engineIPPath, encoding: .utf8) else { return nil } + let ip = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return isIPv4(ip) ? ip : nil + } + + private static func helperProcessIsAlive() -> Bool { + guard let pid = helperPID(), pid > 0 else { return false } + return helperProcessIsAlive(pid: pid) + } + + nonisolated private static func helperProcessIsAlive(pid: pid_t) -> Bool { + return kill(pid, 0) == 0 || errno == EPERM + } + + nonisolated private static func helperPID() -> pid_t? { + guard let raw = try? String(contentsOfFile: helperPIDPath, encoding: .utf8), + let value = Int32(raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return nil } + return pid_t(value) + } + + private static func stopHelper() { + guard let pid = helperPID(), pid > 0 else { + try? FileManager.default.removeItem(atPath: helperPIDPath) + return + } + if kill(pid, SIGTERM) == 0 { + for _ in 0..<20 { + if kill(pid, 0) != 0 { break } + usleep(100_000) + } + if kill(pid, 0) == 0 { _ = kill(pid, SIGKILL) } + } + try? FileManager.default.removeItem(atPath: helperPIDPath) + try? FileManager.default.removeItem(atPath: engineIPPath) + try? FileManager.default.removeItem(atPath: socketPath) + } + + nonisolated static func memoryStringToMB(_ raw: String) -> Int? { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !trimmed.isEmpty else { return nil } + let suffix = trimmed.last + let numberText: Substring + let multiplier: Double + switch suffix { + case "g": + numberText = trimmed.dropLast() + multiplier = 1024 + case "m": + numberText = trimmed.dropLast() + multiplier = 1 + case "k": + numberText = trimmed.dropLast() + multiplier = 1.0 / 1024.0 + default: + numberText = Substring(trimmed) + multiplier = 1.0 / (1024.0 * 1024.0) + } + guard let value = Double(numberText), value > 0 else { return nil } + return max(1, Int((value * multiplier).rounded(.up))) + } } diff --git a/Dory/Shim/DockerShim.swift b/Dory/Shim/DockerShim.swift index b820ebf..1336cb0 100644 --- a/Dory/Shim/DockerShim.swift +++ b/Dory/Shim/DockerShim.swift @@ -958,10 +958,7 @@ struct DockerShim: Sendable { let result = (try? await runtime.exec(containerID: entry.container, command: entry.cmd)) ?? ExecResult(exitCode: 1, output: "") store.setResult(execID, exitCode: result.exitCode) let payload = Data(result.output.utf8) - var frame = Data([1, 0, 0, 0]) - let length = UInt32(payload.count) - frame.append(contentsOf: [UInt8(length >> 24 & 0xff), UInt8(length >> 16 & 0xff), UInt8(length >> 8 & 0xff), UInt8(length & 0xff)]) - frame.append(payload) + let frame = Self.stdcopyFrame(stream: 1, payload: payload) _ = try? UnixSocketHTTP.writeAll(fd, frame) } } @@ -1600,12 +1597,10 @@ struct DockerShim: Sendable { return Data(stat.utf8).base64EncodedString() } - nonisolated private static func logFrame(_ line: LogLine, timestamps: Bool) -> Data { - let prefix = timestamps && !line.timestamp.isEmpty ? "\(line.timestamp) " : "" - let payload = Data("\(prefix)\(line.message)\n".utf8) + nonisolated static func stdcopyFrame(stream: UInt8, payload: Data) -> Data { let length = UInt32(payload.count) - var frame = Data([logStreamType(line), 0, 0, 0]) - frame.append(contentsOf: [ + var frame = Data([ + stream, 0, 0, 0, UInt8(length >> 24 & 0xff), UInt8(length >> 16 & 0xff), UInt8(length >> 8 & 0xff), @@ -1615,6 +1610,12 @@ struct DockerShim: Sendable { return frame } + nonisolated private static func logFrame(_ line: LogLine, timestamps: Bool) -> Data { + let prefix = timestamps && !line.timestamp.isEmpty ? "\(line.timestamp) " : "" + let payload = Data("\(prefix)\(line.message)\n".utf8) + return stdcopyFrame(stream: logStreamType(line), payload: payload) + } + nonisolated private static func logStreamType(_ line: LogLine) -> UInt8 { line.level == .error ? 2 : 1 } diff --git a/DoryTests/AgentModeTests.swift b/DoryTests/AgentModeTests.swift index a6f6e93..9549e4d 100644 --- a/DoryTests/AgentModeTests.swift +++ b/DoryTests/AgentModeTests.swift @@ -68,10 +68,4 @@ struct AgentModeTests { store.windowOpenRequested = false #expect(store.shouldOpenWindowOnLaunch == !store.isAgentMode) } - - @Test func stopEngineCommandTargetsSharedEngine() { - if let command = SharedVMProvisioner.stopEngineCommand() { - #expect(command.arguments == ["stop", "dory-engine"]) - } - } } diff --git a/DoryTests/CredentialBridgeTests.swift b/DoryTests/CredentialBridgeTests.swift new file mode 100644 index 0000000..94cfb45 --- /dev/null +++ b/DoryTests/CredentialBridgeTests.swift @@ -0,0 +1,62 @@ +import Foundation +import Testing +@testable import Dory + +struct CredentialBridgeTests { + @Test func planBuildsStableGuestAndHostSocketPaths() throws { + let root = URL(fileURLWithPath: "/Users/me/.dory/bridge") + let plan = try CredentialBridgePlan(machine: "dev", bridgeRoot: root, hostSSHAuthSock: "/private/tmp/agent.sock") + + #expect(plan.credentialDirectory.path == "/Users/me/.dory/bridge/dev/credentials") + #expect(plan.hostAgentProxySocket.path == "/Users/me/.dory/bridge/dev/credentials/ssh-agent.sock") + #expect(plan.guestSSHAuthSock == "/opt/dory/bridge/credentials/ssh-agent.sock") + #expect(plan.guestEnv["SSH_AUTH_SOCK"] == "/opt/dory/bridge/credentials/ssh-agent.sock") + #expect(plan.guestEnv["GIT_ASKPASS"] == "/usr/local/bin/dory-git-askpass") + } + + @Test func proxyCommandConnectsHostAgentToBridgeSocket() throws { + let plan = try CredentialBridgePlan( + machine: "dev", + bridgeRoot: URL(fileURLWithPath: "/Users/me/.dory/bridge"), + hostSSHAuthSock: "/private/tmp/com.apple.launchd/Listeners" + ) + + #expect(plan.sshAgentProxyCommand == [ + "socat", + "UNIX-LISTEN:/Users/me/.dory/bridge/dev/credentials/ssh-agent.sock,fork,unlink-early,mode=0600", + "UNIX-CONNECT:/private/tmp/com.apple.launchd/Listeners", + ]) + } + + @Test func rejectsUnsafeOrRelativeInputs() { + #expect(throws: CredentialBridgePlan.PlanError.invalidMachineName) { + try CredentialBridgePlan(machine: "../dev", bridgeRoot: URL(fileURLWithPath: "/tmp"), hostSSHAuthSock: "/tmp/agent.sock") + } + #expect(throws: CredentialBridgePlan.PlanError.relativeSSHAuthSock) { + try CredentialBridgePlan(machine: "dev", bridgeRoot: URL(fileURLWithPath: "/tmp"), hostSSHAuthSock: "agent.sock") + } + } + + @Test func missingAgentIsExplicitWhenValidationIsRequested() throws { + let plan = try CredentialBridgePlan(machine: "dev", bridgeRoot: URL(fileURLWithPath: "/tmp"), hostSSHAuthSock: nil) + #expect(plan.sshAgentProxyCommand == nil) + #expect(throws: CredentialBridgePlan.PlanError.missingSSHAuthSock) { + try plan.validateHostAgent() + } + } + + @Test func prepareCreatesCredentialsDirectoryAndRemovesStaleSocketFiles() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("dory-cred-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let plan = try CredentialBridgePlan(machine: "dev", bridgeRoot: root, hostSSHAuthSock: "/tmp/agent.sock") + try FileManager.default.createDirectory(at: plan.credentialDirectory, withIntermediateDirectories: true) + try Data("stale".utf8).write(to: plan.hostAgentProxySocket) + try Data("stale".utf8).write(to: plan.hostGitAskpassSocket) + + try HostCredentialBridge.prepare(plan) + + #expect(FileManager.default.fileExists(atPath: plan.credentialDirectory.path)) + #expect(!FileManager.default.fileExists(atPath: plan.hostAgentProxySocket.path)) + #expect(!FileManager.default.fileExists(atPath: plan.hostGitAskpassSocket.path)) + } +} diff --git a/DoryTests/DockerEngineSocketDiscoveryTests.swift b/DoryTests/DockerEngineSocketDiscoveryTests.swift index b33e548..1a7f92f 100644 --- a/DoryTests/DockerEngineSocketDiscoveryTests.swift +++ b/DoryTests/DockerEngineSocketDiscoveryTests.swift @@ -109,6 +109,53 @@ struct DockerEngineSocketDiscoveryTests { #expect(!candidates.contains(dorySocket)) } + @Test func doryEngineSocketIsAlsoExcluded() throws { + let home = try TempHome.make() + defer { try? FileManager.default.removeItem(at: home) } + + let engineSocket = "\(home.path)/.dory/engine.sock" + let candidates = DockerEngineSocketDiscovery.candidates( + environment: ["DOCKER_HOST": "unix://\(engineSocket)"], + home: home.path + ) + + #expect(!candidates.contains(engineSocket)) + } + + @Test func availableSourcesLabelsEachEngineByVendor() throws { + let home = try TempHome.make() + defer { try? FileManager.default.removeItem(at: home) } + + for relative in [".orbstack/run/docker.sock", ".colima/default/docker.sock", ".rd/docker.sock"] { + let url = home.appendingPathComponent(relative) + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: url.path, contents: Data()) + } + + let sources = DockerEngineSocketDiscovery.availableSources(environment: [:], home: home.path) + let byLabel = Dictionary(uniqueKeysWithValues: sources.map { ($0.label, $0.socketPath) }) + + #expect(byLabel["OrbStack"] == "\(home.path)/.orbstack/run/docker.sock") + #expect(byLabel["Colima"] == "\(home.path)/.colima/default/docker.sock") + #expect(byLabel["Rancher Desktop"] == "\(home.path)/.rd/docker.sock") + } + + @Test func availableSourcesOmitsSocketsThatDoNotExist() throws { + let home = try TempHome.make() + defer { try? FileManager.default.removeItem(at: home) } + + // No socket files created under this temp home → none of its per-home sockets are offered. + // (A real `/var/run/docker.sock` may exist on the host; that is global, not under this home.) + let sources = DockerEngineSocketDiscovery.availableSources(environment: [:], home: home.path) + #expect(!sources.contains { $0.socketPath.hasPrefix(home.path) }) + } + + @Test func engineLabelIdentifiesDockerAndFallsBackToPath() throws { + #expect(DockerEngineSocketDiscovery.engineLabel(for: "/var/run/docker.sock", home: "/Users/x") == "Docker") + #expect(DockerEngineSocketDiscovery.engineLabel(for: "/Users/x/.docker/run/docker.sock", home: "/Users/x") == "Docker") + #expect(DockerEngineSocketDiscovery.engineLabel(for: "/weird/custom.sock", home: "/Users/x") == "/weird/custom.sock") + } + @MainActor @Test func detectSkipsHungSocketAndFindsNextCandidate() async throws { let hungPath = Self.shortSocketPath("dory-detect-hung") diff --git a/DoryTests/ExposeTunnelTests.swift b/DoryTests/ExposeTunnelTests.swift new file mode 100644 index 0000000..a130e10 --- /dev/null +++ b/DoryTests/ExposeTunnelTests.swift @@ -0,0 +1,43 @@ +import Testing +@testable import Dory + +struct ExposeTunnelTests { + @Test func localPortUsesQuickTunnelURL() throws { + let plan = try ExposeTunnelPlan(target: .localPort(3000)) + + #expect(plan.url == "http://127.0.0.1:3000") + #expect(plan.cloudflaredCommand == ["cloudflared", "tunnel", "--url", "http://127.0.0.1:3000"]) + } + + @Test func machineTargetUsesDoryLocalAndNamedHostname() throws { + let plan = try ExposeTunnelPlan( + target: .machine(name: "rust-dev", port: 8080), + hostname: "preview.example.com", + scheme: "https" + ) + + #expect(plan.url == "https://rust-dev.dory.local:8080") + #expect(plan.cloudflaredCommand == [ + "cloudflared", "tunnel", "--hostname", "preview.example.com", + "--url", "https://rust-dev.dory.local:8080", "run" + ]) + } + + @Test func rejectsUnsafeInputs() { + #expect(throws: ExposeTunnelPlan.PlanError.invalidPort) { + try ExposeTunnelPlan(target: .localPort(0)) + } + #expect(throws: ExposeTunnelPlan.PlanError.invalidMachineName) { + try ExposeTunnelPlan(target: .machine(name: "../dev", port: 80)) + } + #expect(throws: ExposeTunnelPlan.PlanError.invalidMachineName) { + try ExposeTunnelPlan(target: .machine(name: "rust_dev", port: 80)) + } + #expect(throws: ExposeTunnelPlan.PlanError.invalidHostname) { + try ExposeTunnelPlan(target: .localPort(3000), hostname: "localhost") + } + #expect(throws: ExposeTunnelPlan.PlanError.unsupportedScheme) { + try ExposeTunnelPlan(target: .localPort(3000), scheme: "ftp") + } + } +} diff --git a/DoryTests/HostBridgeTests.swift b/DoryTests/HostBridgeTests.swift index 4420ed7..81cf198 100644 --- a/DoryTests/HostBridgeTests.swift +++ b/DoryTests/HostBridgeTests.swift @@ -159,6 +159,7 @@ struct HostBridgeTests { let watcher = HostBridgeWatcher(bridgeRoot: root, forwarder: fwd, enabled: true) { _ in } watcher.startWatching(machine: "dev") #expect(watcher.watchedMachines() == ["dev"]) + #expect(FileManager.default.fileExists(atPath: root.appendingPathComponent("dev/credentials").path)) watcher.stopWatching(machine: "dev") #expect(watcher.watchedMachines().isEmpty) fwd.stopAll() @@ -194,6 +195,8 @@ struct HostBridgeTests { let body = MachineService.createBody(name: "dev", distro: MachineDistro.forFamily("ubuntu")!, arch: .arm64, imageTag: "img", keepaliveOnly: true) let env = body["Env"] as! [String] #expect(env.contains("BROWSER=dory-open")) + #expect(env.contains("SSH_AUTH_SOCK=/opt/dory/bridge/credentials/ssh-agent.sock")) + #expect(env.contains("GIT_ASKPASS=/usr/local/bin/dory-git-askpass")) } @Test func bridgeHostDirIsUnderDoryBridge() { diff --git a/DoryTests/MachineProvisionerTests.swift b/DoryTests/MachineProvisionerTests.swift index 2cfe152..6ae9c2c 100644 --- a/DoryTests/MachineProvisionerTests.swift +++ b/DoryTests/MachineProvisionerTests.swift @@ -53,4 +53,12 @@ struct MachineProvisionerTests { let s = MachineProvisioner.script(identity: id(), pkg: .apt, isSystemd: true, includeSSH: false) #expect(s.contains("socat")) } + + @Test func installsCredentialForwardingShim() { + let s = MachineProvisioner.script(identity: id(), pkg: .apt, isSystemd: true, includeSSH: false) + #expect(s.contains("/etc/profile.d/dory-credentials.sh")) + #expect(s.contains("SSH_AUTH_SOCK=/opt/dory/bridge/credentials/ssh-agent.sock")) + #expect(s.contains("/usr/local/bin/dory-git-askpass")) + #expect(s.contains("DORY_GIT_ASKPASS_SOCK=/opt/dory/bridge/credentials/git-askpass.sock")) + } } diff --git a/DoryTests/MachineSnapshotTests.swift b/DoryTests/MachineSnapshotTests.swift index dabcda7..6c7df07 100644 --- a/DoryTests/MachineSnapshotTests.swift +++ b/DoryTests/MachineSnapshotTests.swift @@ -78,6 +78,63 @@ struct DoryMachineFileTests { } } +struct SnapshotScheduleTests { + @Test func scheduleCalculatesDueAndNextRun() throws { + let schedule = MachineSnapshotSchedule(machineName: "dev", frequency: .daily, keepLocal: 5) + try schedule.validate() + let last = Date(timeIntervalSince1970: 1_700_000_000) + + #expect(schedule.isDue(lastSnapshotAt: nil, now: last)) + #expect(!schedule.isDue(lastSnapshotAt: last, now: last.addingTimeInterval(23 * 60 * 60))) + #expect(schedule.isDue(lastSnapshotAt: last, now: last.addingTimeInterval(24 * 60 * 60))) + #expect(schedule.nextRun(after: last) == last.addingTimeInterval(24 * 60 * 60)) + } + + @Test func scheduleRejectsUnsafeValues() { + #expect(throws: SnapshotScheduleError.invalidMachineName) { + try MachineSnapshotSchedule(machineName: "../dev", frequency: .daily).validate() + } + #expect(throws: SnapshotScheduleError.invalidRetention) { + try MachineSnapshotSchedule(machineName: "dev", frequency: .daily, keepLocal: 0).validate() + } + #expect(throws: SnapshotScheduleError.invalidBucket) { + try MachineSnapshotSchedule( + machineName: "dev", + frequency: .daily, + s3: S3BackupDestination(bucket: "Bad_Bucket") + ).validate() + } + #expect(throws: SnapshotScheduleError.invalidPrefix) { + try MachineSnapshotSchedule( + machineName: "dev", + frequency: .daily, + s3: S3BackupDestination(bucket: "dory-backups", prefix: "../escape") + ).validate() + } + } + + @Test func s3BackupDestinationBuildsStableObjectURL() throws { + let snapshot = MachineSnapshot( + id: "sha256:abc/123", + imageRef: "dory-snapshot/dev:s17", + machineName: "dev", + note: "nightly", + createdISO: "2026-07-04T17:00:00Z", + sizeBytes: 42, + distro: "Ubuntu", + version: "24.04 LTS", + arch: "arm64", + boot: "systemd", + recipe: "node" + ) + let destination = S3BackupDestination(bucket: "dory-backups", prefix: "machines/dev", region: "us-east-1") + try destination.validate() + + #expect(destination.objectKey(for: snapshot) == "machines/dev/dev-20260704T170000Z-sha256-abc-123.tar") + #expect(destination.url(for: snapshot) == "s3://dory-backups/machines/dev/dev-20260704T170000Z-sha256-abc-123.tar") + } +} + struct DevRecipeTests { @Test func catalogHasSevenRecipes() { #expect(DevRecipe.all.map(\.id) == ["node", "python", "go", "java", "ruby", "rust", "devops"]) @@ -109,6 +166,27 @@ struct DevRecipeTests { #expect(df.contains("nodejs")) #expect(!df.contains("/sbin/init")) } + + @Test func schemaRecipeDockerfileInstallsPackagesBeforeCommands() throws { + let recipe = DevRecipe( + id: "rust-dev", + display: "Rust Dev", + icon: "r.circle", + install: "", + packages: ["build-essential", "pkg-config"], + runcmd: ["echo ready"] + ) + let df = MachineImageBuilder.recipeDockerfile(baseImageTag: "base", recipe: recipe, packageManager: .apt) + #expect(df.contains("apt-get install -y --no-install-recommends 'build-essential' 'pkg-config'")) + #expect(df.range(of: "apt-get install")!.lowerBound < df.range(of: "echo ready")!.lowerBound) + } + + @Test func recipeProvisionScriptUsesDistroPackageManager() { + let recipe = DevRecipe(id: "tools", display: "Tools", icon: "wrench", install: "", packages: ["git"], runcmd: []) + #expect(recipe.provisionScript(packageManager: .dnf).contains("dnf install -y 'git'")) + #expect(recipe.provisionScript(packageManager: .apk).contains("apk add --no-cache 'git'")) + #expect(recipe.provisionScript(packageManager: .pacman).contains("pacman -Sy --noconfirm --needed 'git'")) + } } struct MachineSettingsTests { diff --git a/DoryTests/MachineTests.swift b/DoryTests/MachineTests.swift index 92fbd73..bbe7950 100644 --- a/DoryTests/MachineTests.swift +++ b/DoryTests/MachineTests.swift @@ -52,6 +52,7 @@ struct MachineImageBuilderTests { let df = MachineImageBuilder.dockerfile(for: MachineDistro.forImage("ubuntu:24.04")!) #expect(df.contains("FROM ubuntu:24.04")) #expect(df.contains("systemd-sysv")) + #expect(df.contains("netcat-openbsd")) #expect(df.contains("STOPSIGNAL SIGRTMIN+3")) #expect(df.contains("CMD [\"/sbin/init\"]")) } @@ -103,6 +104,10 @@ struct MachineServiceHelperTests { #expect(host?["Privileged"] as? Bool == true) #expect(host?["CgroupnsMode"] as? String == "host") #expect((host?["Tmpfs"] as? [String: String])?["/run"] == "") + #expect(host?["ExtraHosts"] as? [String] == [ + "host.docker.internal:host-gateway", + "host.dory.internal:host-gateway", + ]) } @Test func createBodyKeepaliveOverridesInit() { @@ -139,4 +144,48 @@ struct MachineServiceHelperTests { #expect(machines[0].ip == "172.17.0.5") #expect(machines[0].letter == "U") } + + @Test func recipeSettingsMapResourcesPortsMountsEnvAndUser() throws { + let recipe = DevRecipe( + id: "node", + display: "Node", + icon: "terminal", + install: "", + distro: "ubuntu:24.04", + arch: "arm64", + resources: .init(cpus: 4, memory: "8GiB", disk: "60GiB"), + mounts: ["/Users/{{host_user}}/src:/workspace:ro"], + ports: [3000, 9229], + env: ["NODE_ENV": "development"], + user: .init(name: "{{host_user}}", sudo: true, shell: "/bin/zsh") + ) + + let settings = try MachineService.settings( + from: recipe, + hostUser: "augustus", + uid: 777, + homePath: "/Users/augustus", + publicKeys: ["ssh-ed25519 AAAA"] + ) + + #expect(settings.cpus == 4) + #expect(settings.memoryMB == 8192) + #expect(settings.mounts == [MountPair(host: "/Users/augustus/src", guest: "/workspace", readOnly: true)]) + #expect(settings.ports == [PortPair(host: 3000, guest: 3000), PortPair(host: 9229, guest: 9229)]) + #expect(settings.env == ["NODE_ENV": "development"]) + #expect(settings.identity == MacIdentity(username: "augustus", uid: 777, homePath: "/Users/augustus", shell: "/bin/zsh", publicKeys: ["ssh-ed25519 AAAA"])) + } + + @Test func recipeDistroAndArchResolveFromSchema() throws { + let recipe = DevRecipe(id: "py", display: "Python", icon: "terminal", install: "", distro: "fedora:41", arch: "amd64") + #expect(try MachineService.distro(for: recipe).pkg == .dnf) + #expect(try MachineService.arch(for: recipe) == .amd64) + } + + @Test func recipeSettingsRejectBadMounts() { + let recipe = DevRecipe(id: "bad", display: "Bad", icon: "terminal", install: "", mounts: ["not-a-bind"]) + #expect(throws: MachineError.self) { + _ = try MachineService.settings(from: recipe, hostUser: "me") + } + } } diff --git a/DoryTests/NetworkingTests.swift b/DoryTests/NetworkingTests.swift index cb1d6a8..0b4a175 100644 --- a/DoryTests/NetworkingTests.swift +++ b/DoryTests/NetworkingTests.swift @@ -28,6 +28,26 @@ struct NetworkingTests { #expect(Array(response.suffix(4)) == [127, 0, 0, 1]) // A record RDATA } + @Test func dnsUsesExactHostOverrideForMachines() throws { + let query = dnsQuery(name: "dev.dory.local", qtype: 1) + let response = try #require(DoryDNS.makeResponse( + query, + suffix: "dory.local", + ip: "127.0.0.1", + hostIPs: ["dev.dory.local": "172.17.0.5"] + )) + #expect(response[7] == 0x01) + #expect(Array(response.suffix(4)) == [172, 17, 0, 5]) + + let fallback = try #require(DoryDNS.makeResponse( + dnsQuery(name: "web.dory.local", qtype: 1), + suffix: "dory.local", + ip: "127.0.0.1", + hostIPs: ["dev.dory.local": "172.17.0.5"] + )) + #expect(Array(fallback.suffix(4)) == [127, 0, 0, 1]) + } + @Test func dnsRefusesForeignDomains() { #expect(DoryDNS.makeResponse(dnsQuery(name: "google.com", qtype: 1), suffix: "dory.local", ip: "127.0.0.1") == nil) } @@ -65,6 +85,15 @@ struct NetworkingTests { #expect(backend?.pathPrefix.contains("services/web:80/proxy") == true) } + @Test func machineDNSHostsIncludeOnlyRunningMachinesWithIPv4() { + let machines = [ + Machine(name: "dev", distro: "Ubuntu", version: "24.04", status: .running, cpuPercent: 0, memoryDisplay: "0", ip: "172.17.0.5", letter: "U", badgeHex: 0), + Machine(name: "off", distro: "Ubuntu", version: "24.04", status: .stopped, cpuPercent: 0, memoryDisplay: "0", ip: "172.17.0.6", letter: "U", badgeHex: 0), + Machine(name: "pending", distro: "Ubuntu", version: "24.04", status: .running, cpuPercent: 0, memoryDisplay: "0", ip: "—", letter: "U", badgeHex: 0), + ] + #expect(AppStore.machineDNSHosts(machines, suffix: "dory.local") == ["dev.dory.local": "172.17.0.5"]) + } + @Test func kubeServiceProxyBuildsStableServiceRoutes() { #expect(KubeServiceProxy.serviceHost(name: "Web", namespace: "Default", suffix: "dory.local") == "web.default.k8s.dory.local") #expect(KubeServiceProxy.serviceProxyPath(name: "web", namespace: "default", port: 8080) == "/api/v1/namespaces/default/services/web:8080/proxy") diff --git a/DoryTests/RecipeStoreTests.swift b/DoryTests/RecipeStoreTests.swift new file mode 100644 index 0000000..ab4aaf0 --- /dev/null +++ b/DoryTests/RecipeStoreTests.swift @@ -0,0 +1,86 @@ +import Foundation +import Testing +@testable import Dory + +struct RecipeStoreTests { + @Test func validRecipeLoadsAndSubstitutesTemplates() throws { + let url = try writeRecipe(""" + name: rust-dev + summary: Rust toolchain + distro: ubuntu:24.04 + arch: arm64 + resources: {cpus: 4, memory: 8GiB, disk: 60GiB} + packages: [build-essential, pkg-config, libssl-dev] + runcmd: + - echo /home/{{user}} + mounts: + - ~/Projects:~/Projects + ports: [3000] + env: {CARGO_HOME: /home/{{user}}/.cargo} + ssh: {agent_forward: true} + docker: true + user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} + """) + + let recipe = try DevRecipe.load(from: url) + #expect(recipe.id == "rust-dev") + #expect(recipe.resources.memory == "8GiB") + #expect(recipe.packages == ["build-essential", "pkg-config", "libssl-dev"]) + #expect(recipe.ports == [3000]) + #expect(recipe.ssh.agentForward) + #expect(recipe.docker) + + let substituted = recipe.substituted(hostUser: "augustus") + #expect(substituted.user.name == "augustus") + #expect(substituted.env["CARGO_HOME"] == "/home/augustus/.cargo") + } + + @Test func unknownKeysAreRejected() throws { + let url = try writeRecipe(""" + name: bad + distro: ubuntu:24.04 + surprise: nope + """) + #expect(throws: DevRecipe.RecipeError.self) { + _ = try DevRecipe.load(from: url) + } + } + + @Test func badMemoryStringIsRejected() throws { + let url = try writeRecipe(""" + name: bad + distro: ubuntu:24.04 + resources: {cpus: 2, memory: lots, disk: 20GiB} + """) + #expect(throws: DevRecipe.RecipeError.self) { + _ = try DevRecipe.load(from: url) + } + } + + @Test func storeLoadsYamlFilesSortedByID() throws { + let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("dory-recipes-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try "name: zed\ndistro: ubuntu:24.04\n".write(to: dir.appendingPathComponent("zed.yaml"), atomically: true, encoding: .utf8) + try "name: alpha\ndistro: alpine:3.20\n".write(to: dir.appendingPathComponent("alpha.yml"), atomically: true, encoding: .utf8) + + let recipes = try RecipeStore(userDirectory: dir, builtInDirectory: nil).loadAll() + #expect(recipes.map(\.id) == ["alpha", "zed"]) + } + + @Test func sourceBuiltInCatalogLoads() throws { + let sourceFile = URL(fileURLWithPath: #filePath) + let repo = sourceFile.deletingLastPathComponent().deletingLastPathComponent() + let catalog = repo.appendingPathComponent("Dory/Resources/Recipes") + let recipes = try RecipeStore(userDirectory: catalog.appendingPathComponent("missing"), builtInDirectory: catalog).loadAll() + #expect(recipes.map(\.id) == ["docker-host", "go", "k8s-lab", "node", "python-ml", "rust", "ubuntu-dev"]) + #expect(recipes.first { $0.id == "docker-host" }?.docker == true) + #expect(recipes.first { $0.id == "rust" }?.env["CARGO_HOME"] == "/home/{{user}}/.cargo") + } + + private func writeRecipe(_ text: String) throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("dory-recipe-\(UUID().uuidString).yaml") + try text.write(to: url, atomically: true, encoding: .utf8) + return url + } +} diff --git a/DoryTests/RuntimeHelpersTests.swift b/DoryTests/RuntimeHelpersTests.swift index ca73e53..13eb1cd 100644 --- a/DoryTests/RuntimeHelpersTests.swift +++ b/DoryTests/RuntimeHelpersTests.swift @@ -81,45 +81,6 @@ struct RuntimeHelpersTests { #expect(parsePublishedPorts(display) == [PublishedPort(hostPort: 5353, containerPort: 53, proto: "udp")]) } - @Test func mapsDistroInfo() { - #expect(AppleContainerRuntime.distroInfo("ubuntu-dev").distro == "Ubuntu") - #expect(AppleContainerRuntime.distroInfo("ubuntu-dev").letter == "U") - #expect(AppleContainerRuntime.distroInfo("my-alpine").distro == "Alpine") - #expect(AppleContainerRuntime.distroInfo("dory-mach").distro == "Linux") - #expect(AppleContainerRuntime.distroInfo("dory-mach").letter == "D") - } - - @Test func appleCreateArgumentsPreserveDockerCreateFields() { - var spec = ContainerSpec(name: "amd", image: "alpine:3.22", platform: " linux/amd64 ") - spec.command = ["sh", "-lc", "true"] - spec.domainname = "svc.dory.local" - spec.environment = ["B": "2", "A": "1"] - spec.dns = ["1.1.1.1"] - spec.ports = ["8080:80"] - spec.labels = ["role": "web"] - spec.networkMode = "backend" - spec.networkDisabled = true - spec.containerIDFile = "/tmp/dory.cid" - spec.runtimeName = "container-runtime-linux" - - #expect(AppleContainerRuntime.createArguments(for: spec) == [ - "create", "--name", "amd", - "--platform", "linux/amd64", - "--cidfile", "/tmp/dory.cid", - "--runtime", "container-runtime-linux", - "-e", "A=1", - "-e", "B=2", - "-p", "8080:80", - "--label", "role=web", - "--dns", "1.1.1.1", - "--dns-domain", "svc.dory.local", - "--no-dns", - "--network", "none", - "--", "alpine:3.22", - "sh", "-lc", "true", - ]) - } - @Test func mapsKubernetesPhase() { #expect(KubeRowMapper.podPhase("Running", statuses: []) == .running) #expect(KubeRowMapper.podPhase("Pending", statuses: []) == .pending) diff --git a/DoryTests/RuntimeSupportTests.swift b/DoryTests/RuntimeSupportTests.swift index b22f7fb..5400a6f 100644 --- a/DoryTests/RuntimeSupportTests.swift +++ b/DoryTests/RuntimeSupportTests.swift @@ -3,85 +3,125 @@ import Testing @testable import Dory struct RuntimeSupportTests { - @Test func appleContainerRequiresMacOS26OrLater() { - let platform = MacHostPlatform(major: 15, minor: 7, patch: 0, architecture: "arm64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: true) - #expect(!support.isSupported) - #expect(support.reason == "requires macOS 26 or later for Apple's container engine") - #expect(support.issue == .osVersion) + // Dory's own engine (dory-hv) runs on Hypervisor.framework's GICv3 — macOS 15+ Apple silicon, + // no Apple `container` toolchain. That is the sole shared-VM engine and its host requirement. + @Test func engineSupportsMacOS15AppleSilicon() { + let sequoia = MacHostPlatform(major: 15, minor: 0, patch: 0, architecture: "arm64") + let support = SharedVMProvisioner.hostSupport(platform: sequoia, engineAvailable: true) + #expect(support.isSupported) + #expect(support.issue == RuntimeSupport.Issue.none) } - @Test func missingToolchainIsReportedAsTypedIssue() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "arm64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: false) - #expect(support.issue == .missingToolchain) + @Test func engineSupportsCurrentMacOSAppleSilicon() { + let tahoe = MacHostPlatform(major: 26, minor: 1, patch: 0, architecture: "arm64") + #expect(SharedVMProvisioner.hostSupport(platform: tahoe, engineAvailable: true).isSupported) } - @Test func architectureIssueIsTypedAndNotFixableByInstall() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "x86_64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: false) + @Test func engineRequiresAppleSilicon() { + // Architecture is unfixable, so it is reported before the engine-availability check. + let intel = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "x86_64") + let support = SharedVMProvisioner.hostSupport(platform: intel, engineAvailable: true) + #expect(!support.isSupported) #expect(support.issue == .architecture) } - @Test func supportedHostReportsNoIssue() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "arm64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: true) - #expect(support.issue == RuntimeSupport.Issue.none) + @Test func engineRejectsMacOSOlderThan15() { + let ventura = MacHostPlatform(major: 14, minor: 5, patch: 0, architecture: "arm64") + let support = SharedVMProvisioner.hostSupport(platform: ventura, engineAvailable: true) + #expect(!support.isSupported) + #expect(support.issue == .osVersion) } - @Test func toolchainInstallCommandTargetsHomebrewFormula() { - #expect(AppStore.toolchainInstallCommand == "brew install container") + @Test func capableHardwareIsUnsupportedWhenEngineUnavailable() { + // Right Mac, but the engine's binaries/kernel are missing or the user opted out + // (DORY_HV_ENGINE=0): report unavailable so the app falls back to a Docker-compatible + // engine rather than showing a misleading boot failure. + let sequoia = MacHostPlatform(major: 15, minor: 4, patch: 0, architecture: "arm64") + let support = SharedVMProvisioner.hostSupport(platform: sequoia, engineAvailable: false) + #expect(!support.isSupported) + #expect(support.issue == .missingToolchain) } - @Test func toolchainReleasesURLIsValid() { - let url = URL(string: AppStore.toolchainReleasesURL) - #expect(url != nil) - #expect(url?.host == "github.com") + @Test func doryHVSupportEvaluatesArchitectureBeforeOSVersion() { + // An Intel Mac on an old macOS reports the architecture (the unfixable requirement) first. + let oldIntel = MacHostPlatform(major: 13, minor: 0, patch: 0, architecture: "x86_64") + #expect(DoryHVSupport.evaluate(platform: oldIntel).issue == .architecture) } - @Test func toolchainInstallPhaseBusyStates() { - #expect(ToolchainInstallPhase.installing.isBusy) - #expect(ToolchainInstallPhase.startingEngine.isBusy) - #expect(!ToolchainInstallPhase.idle.isBusy) - #expect(!ToolchainInstallPhase.failed("x").isBusy) + @Test func hvEngineDisabledByOptOutFlag() { + // DORY_HV_ENGINE=0 force-disables the engine even when binaries are present. + #expect(!SharedVMProvisioner.hvEngineAvailable(environment: ["DORY_HV_ENGINE": "0"])) } - @Test @MainActor func needsContainerToolchainOnlyWhenEngineOffWithMissingToolchain() { - let store = AppStore() - store.sharedVMSupport = .unsupported("needs Apple's container toolchain", issue: .missingToolchain) - store.loadState = .engineOff - #expect(store.needsContainerToolchain) - - store.loadState = .ready - #expect(!store.needsContainerToolchain) - - store.loadState = .engineOff - store.sharedVMSupport = .unsupported("requires Apple silicon for Apple's container engine", issue: .architecture) - #expect(!store.needsContainerToolchain) + @Test func sharedVMDefaultMemoryPolicyIsBelowLegacyFourGiB() { + let config = SharedVMProvisioner.Config() + #expect(config.memory == "2048M") + #expect(config.memoryMB == 2048) + #expect(config.headroomMB == 512) + } - store.sharedVMSupport = .supported - #expect(!store.needsContainerToolchain) + @Test func sharedVMMemoryParserHandlesDockerStyleUnits() { + #expect(SharedVMProvisioner.memoryStringToMB("2G") == 2048) + #expect(SharedVMProvisioner.memoryStringToMB("1536M") == 1536) + #expect(SharedVMProvisioner.memoryStringToMB("1073741824") == 1024) } - @Test func appleContainerRequiresAppleSilicon() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "x86_64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: true) - #expect(!support.isSupported) - #expect(support.reason == "requires Apple silicon for Apple's container engine") + @Test func sharedVMEngineArgumentsStartDirectIPBridge() { + let arguments = SharedVMProvisioner.engineArguments( + config: SharedVMProvisioner.Config(cpus: 6, memory: "3G"), + kernel: "/tmp/kernel", + gvproxy: "/tmp/gvproxy", + rootfs: "/tmp/rootfs.ext4" + ) + + #expect(arguments.contains("--direct-ip")) + #expect(argumentValue(after: "--kernel", in: arguments) == "/tmp/kernel") + #expect(argumentValue(after: "--gvproxy", in: arguments) == "/tmp/gvproxy") + #expect(argumentValue(after: "--rootfs", in: arguments) == "/tmp/rootfs.ext4") + #expect(argumentValue(after: "--mem-mb", in: arguments) == "3072") + #expect(argumentValue(after: "--cpus", in: arguments) == "6") } - @Test func appleContainerRequiresToolchain() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "arm64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: false) - #expect(!support.isSupported) - #expect(support.reason == "needs Apple's container toolchain") + @Test func sharedVMEngineArgumentsShareHomeAtItsRealPath() { + let arguments = SharedVMProvisioner.engineArguments( + config: SharedVMProvisioner.Config(cpus: 4, memory: "2G"), + kernel: "/tmp/kernel", + gvproxy: "/tmp/gvproxy", + rootfs: nil + ) + + let home = NSHomeDirectory() + #expect(argumentValue(after: "--share", in: arguments) == "home=\(home):rw:at=\(home):safe") } - @Test func appleContainerIsSupportedWhenAllRequirementsMatch() { - let platform = MacHostPlatform(major: 26, minor: 0, patch: 0, architecture: "arm64") - let support = AppleContainerSupport.evaluate(platform: platform, hasContainerCLI: true) - #expect(support.isSupported) - #expect(support.reason.isEmpty) + @Test func wakeClockResyncSignalsLiveHelperOnly() { + var sent: [(pid_t, Int32)] = [] + let signaler: (pid_t, Int32) -> Int32 = { pid, signal in + sent.append((pid, signal)) + return 0 + } + + #expect(SharedVMProvisioner.resyncClockAfterWake( + pid: 1234, + isAlive: { $0 == 1234 }, + signalSender: signaler + )) + #expect(sent.count == 1) + #expect(sent[0].0 == 1234) + #expect(sent[0].1 == SIGUSR1) + + sent.removeAll() + #expect(!SharedVMProvisioner.resyncClockAfterWake( + pid: 1234, + isAlive: { _ in false }, + signalSender: signaler + )) + #expect(sent.isEmpty) + #expect(!SharedVMProvisioner.resyncClockAfterWake( + pid: nil, + isAlive: { _ in true }, + signalSender: signaler + )) } @Test func dockerCompatibleRequirementNamesOlderMacFallbacks() { @@ -94,7 +134,7 @@ struct RuntimeSupportTests { } @Test func sharedVMUnavailableStatusPointsOlderMacsToDockerCompatibleFallbacks() { - let support = RuntimeSupport.unsupported("requires Apple silicon for Apple's container engine") + let support = RuntimeSupport.unsupported("Dory's engine requires Apple silicon") let message = AppStore.sharedVMUnavailableStatus(support) #expect(message.contains("Dory's shared VM is unavailable")) #expect(message.contains("Docker-compatible engine")) @@ -102,4 +142,12 @@ struct RuntimeSupportTests { #expect(message.contains("Colima")) #expect(message.contains("Podman")) } + + private func argumentValue(after flag: String, in arguments: [String]) -> String? { + guard let index = arguments.firstIndex(of: flag), + arguments.indices.contains(arguments.index(after: index)) else { + return nil + } + return arguments[arguments.index(after: index)] + } } diff --git a/DoryTests/SSHConfigWriterTests.swift b/DoryTests/SSHConfigWriterTests.swift new file mode 100644 index 0000000..bd19d62 --- /dev/null +++ b/DoryTests/SSHConfigWriterTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import Dory + +struct SSHConfigWriterTests { + @Test func forwardedPortHostBlockUsesLocalhostPort() { + let machine = Machine( + name: "rusty", + distro: "Ubuntu", + version: "24.04 LTS", + status: .running, + cpuPercent: 0, + memoryDisplay: "", + ip: "172.17.0.2", + letter: "U", + badgeHex: 0, + username: "augustus", + loginShell: "/bin/bash", + sshPort: 32022 + ) + + let block = SSHConfigWriter.hostBlock(for: machine) + #expect(block.contains("Host rusty")) + #expect(block.contains(" HostName 127.0.0.1")) + #expect(block.contains(" User augustus")) + #expect(block.contains(" Port 32022")) + #expect(!block.contains("ProxyCommand")) + } + + @Test func missingForwardedPortUsesDockerExecProxyCommand() { + let machine = Machine( + name: "Dev Box", + distro: "Ubuntu", + version: "24.04 LTS", + status: .running, + cpuPercent: 0, + memoryDisplay: "", + ip: "172.17.0.2", + letter: "U", + badgeHex: 0, + username: "dev", + loginShell: "/bin/zsh" + ) + + let block = SSHConfigWriter.hostBlock(for: machine) + #expect(block.contains("Host dev-box")) + #expect(block.contains(" User dev")) + #expect(block.contains("ProxyCommand docker -H unix://$HOME/.dory/dory.sock exec -i 'dory-machine-Dev Box' sh -lc 'exec nc 127.0.0.1 22'")) + } + + @Test func writeCreatesParentDirectoryAndSortedConfig() throws { + let root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-ssh-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let writer = SSHConfigWriter(configURL: root.appendingPathComponent("ssh/config")) + let zed = Machine(name: "zed", distro: "Ubuntu", version: "", status: .running, cpuPercent: 0, memoryDisplay: "", ip: "", letter: "U", badgeHex: 0, sshPort: 2202) + let alpha = Machine(name: "alpha", distro: "Ubuntu", version: "", status: .running, cpuPercent: 0, memoryDisplay: "", ip: "", letter: "U", badgeHex: 0, sshPort: 2201) + + try writer.write(machines: [zed, alpha]) + let text = try String(contentsOf: root.appendingPathComponent("ssh/config"), encoding: .utf8) + + #expect(text.range(of: "Host alpha")!.lowerBound < text.range(of: "Host zed")!.lowerBound) + #expect(SSHConfigWriter.includeInstruction == "Include ~/.dory/ssh/config") + } +} diff --git a/DoryTests/StreamingAndEncodingTests.swift b/DoryTests/StreamingAndEncodingTests.swift index 0d39a77..8999ba7 100644 --- a/DoryTests/StreamingAndEncodingTests.swift +++ b/DoryTests/StreamingAndEncodingTests.swift @@ -12,6 +12,15 @@ struct StreamingAndEncodingTests { return data } + @Test func stdcopyFrameMatchesReferenceFramingAndRoundTrips() { + let payload = Data("hello world\n".utf8) + let built = DockerShim.stdcopyFrame(stream: 1, payload: payload) + #expect(built == frame("hello world\n", stream: 1)) + #expect(DockerShim.stdcopyFrame(stream: 2, payload: payload).first == 2) + let lines = LogStreamDecoder().feed(built) + #expect(lines.first?.message == "hello world") + } + @Test func logStreamDecoderParsesCompleteFrames() { let decoder = LogStreamDecoder() let lines = decoder.feed(frame("2026-06-18T12:00:00.123456789Z hello world\n")) diff --git a/DoryTests/TunRouterTests.swift b/DoryTests/TunRouterTests.swift new file mode 100644 index 0000000..401271e --- /dev/null +++ b/DoryTests/TunRouterTests.swift @@ -0,0 +1,118 @@ +import Foundation +import Testing +@testable import Dory + +struct TunRouterTests { + @Test func buildsApplyAndTeardownRouteCommands() throws { + let plan = try TunRouter.plan(subnetCIDR: "192.168.215.0/24", gateway: "10.0.2.2") + + #expect(plan.interfaceName == "utun-dory") + #expect(plan.subnetCIDR == "192.168.215.0/24") + #expect(plan.hostGateway == "192.168.127.1") + #expect(plan.gateway == "10.0.2.2") + #expect(plan.interfaceCommand == ["/sbin/ifconfig", "utun-dory", "inet", "192.168.127.1", "10.0.2.2", "up"]) + #expect(plan.routeCommand == ["/sbin/route", "-n", "add", "-net", "192.168.215.0/24", "-interface", "utun-dory"]) + #expect(plan.teardownCommand == ["/sbin/route", "-n", "delete", "-net", "192.168.215.0/24"]) + #expect(plan.enableNetworkingArguments == ["--direct-ip", "--container-subnet", "192.168.215.0/24", "--host-gateway", "192.168.127.1", "--guest-gateway", "10.0.2.2"]) + #expect(plan.disableNetworkingArguments == ["--remove", "--direct-ip", "--container-subnet", "192.168.215.0/24"]) + } + + @Test func rejectsUnsafeRouteInputs() { + #expect(throws: TunRouter.RouterError.invalidInterface) { + try TunRouter.plan(interfaceName: "utun0;rm", subnetCIDR: "192.168.215.0/24", gateway: "10.0.2.2") + } + #expect(throws: TunRouter.RouterError.invalidCIDR) { + try TunRouter.plan(subnetCIDR: "192.168.215.0/33", gateway: "10.0.2.2") + } + #expect(throws: TunRouter.RouterError.invalidCIDR) { + try TunRouter.plan(subnetCIDR: "192.168.999.0/24", gateway: "10.0.2.2") + } + #expect(throws: TunRouter.RouterError.invalidHostGateway) { + try TunRouter.plan(subnetCIDR: "192.168.215.0/24", hostGateway: "192.168.127.999", gateway: "10.0.2.2") + } + #expect(throws: TunRouter.RouterError.invalidGateway) { + try TunRouter.plan(subnetCIDR: "192.168.215.0/24", gateway: "10.0.2.999") + } + } + + @Test func packetBridgeInjectsIPv4FramesForContainerSubnet() throws { + let bridge = try TunRouter.PacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "10.0.0.10", destination: "192.168.215.42", protocolNumber: 1) + let decision = bridge.classifyOutboundUtunFrame(TunRouter.PacketBridge.utunIPv4Header + packet) + + #expect(decision == .injectToGuest(packet: packet, destination: TunRouter.IPv4Address("192.168.215.42")!)) + } + + @Test func packetBridgeIgnoresMalformedAndOutOfRouteFrames() throws { + let bridge = try TunRouter.PacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let foreign = ipv4Packet(source: "10.0.0.10", destination: "10.20.30.40", protocolNumber: 6) + + #expect(bridge.classifyOutboundUtunFrame(Data([0, 0, 0, 30]) + foreign) == .ignore(reason: "not an IPv4 utun frame")) + #expect(bridge.classifyOutboundUtunFrame(TunRouter.PacketBridge.utunIPv4Header + Data([0x45, 0])) == .ignore(reason: "not an IPv4 utun frame")) + #expect(bridge.classifyOutboundUtunFrame(TunRouter.PacketBridge.utunIPv4Header + foreign) == .ignore(reason: "destination outside routed subnet")) + } + + @Test func packetBridgeWrapsGuestIPv4PacketsForUtun() throws { + let bridge = try TunRouter.PacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "192.168.215.42", destination: "10.0.0.10", protocolNumber: 1) + + #expect(bridge.wrapInboundPacketForUtun(packet) == TunRouter.PacketBridge.utunIPv4Header + packet) + #expect(bridge.wrapInboundPacketForUtun(Data([1, 2, 3])) == nil) + } + + @Test func packetBridgeFramesIPv4PacketsForGvproxyVfkitSocket() throws { + let bridge = try TunRouter.PacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "10.0.0.10", destination: "192.168.215.42", protocolNumber: 1) + let frame = try #require(bridge.ethernetFrameForGvproxy(packet)) + + #expect(Array(frame.prefix(6)) == TunRouter.PacketBridge.guestMAC) + #expect(Array(frame.dropFirst(6).prefix(6)) == TunRouter.PacketBridge.bridgeMAC) + #expect(Array(frame.dropFirst(12).prefix(2)) == [0x08, 0x00]) + #expect(frame.dropFirst(14) == packet) + #expect(bridge.ethernetFrameForGvproxy(Data([1, 2, 3])) == nil) + } + + @Test func packetBridgeExtractsIPv4PacketsFromGvproxyFrames() throws { + let bridge = try TunRouter.PacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "192.168.215.42", destination: "10.0.0.10", protocolNumber: 1) + let frame = try #require(bridge.ethernetFrameForGvproxy(packet)) + + #expect(bridge.ipv4PacketFromGvproxyFrame(frame) == packet) + #expect(bridge.ipv4PacketFromGvproxyFrame(Data(TunRouter.PacketBridge.guestMAC + TunRouter.PacketBridge.bridgeMAC + [0x86, 0xdd]) + packet) == nil) + #expect(bridge.ipv4PacketFromGvproxyFrame(Data([1, 2, 3])) == nil) + } + + @Test func ipv4RouteUsesCIDRPrefixMask() throws { + let route = try TunRouter.IPv4Route(cidr: "192.168.208.0/20") + + #expect(route.contains(TunRouter.IPv4Address("192.168.215.42")!)) + #expect(route.contains(TunRouter.IPv4Address("192.168.223.254")!)) + #expect(!route.contains(TunRouter.IPv4Address("192.168.224.1")!)) + } + + private func ipv4Packet(source: String, destination: String, protocolNumber: UInt8) -> Data { + let sourceAddress = TunRouter.IPv4Address(source)!.rawValue + let destinationAddress = TunRouter.IPv4Address(destination)!.rawValue + var packet = Data([ + 0x45, 0x00, + 0x00, 0x1c, + 0x12, 0x34, + 0x00, 0x00, + 0x40, protocolNumber, + 0x00, 0x00, + ]) + packet.append(contentsOf: bytes(sourceAddress)) + packet.append(contentsOf: bytes(destinationAddress)) + packet.append(contentsOf: [0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef]) + return packet + } + + private func bytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ] + } +} diff --git a/DoryTests/UsbAttachmentStoreTests.swift b/DoryTests/UsbAttachmentStoreTests.swift new file mode 100644 index 0000000..a57b940 --- /dev/null +++ b/DoryTests/UsbAttachmentStoreTests.swift @@ -0,0 +1,73 @@ +import Foundation +import Testing +@testable import Dory + +struct UsbAttachmentStoreTests { + @Test func remembersAttachmentsSortedByMachineAndBusID() throws { + let defaults = try makeDefaults() + let store = UsbAttachmentStore(defaults: defaults, key: "usb") + + _ = try store.remember(machine: "zed", busID: "2-1", port: 3, now: Date(timeIntervalSince1970: 20)) + _ = try store.remember(machine: "dev", busID: "1-4", port: 2, now: Date(timeIntervalSince1970: 10)) + _ = try store.remember(machine: "dev", busID: "1-2", port: 1, now: Date(timeIntervalSince1970: 5)) + + #expect(store.attachments().map(\.id) == ["dev:1-2", "dev:1-4", "zed:2-1"]) + } + + @Test func rememberReplacesExistingMachineBusIDPair() throws { + let defaults = try makeDefaults() + let store = UsbAttachmentStore(defaults: defaults, key: "usb") + + _ = try store.remember(machine: "dev", busID: "1-2", port: 1, now: Date(timeIntervalSince1970: 1)) + _ = try store.remember(machine: "dev", busID: "1-2", port: 4, now: Date(timeIntervalSince1970: 2)) + + #expect(store.attachments() == [ + UsbAttachment(machine: "dev", busID: "1-2", port: 4, createdAt: Date(timeIntervalSince1970: 2)) + ]) + } + + @Test func forgetRemovesOneAttachment() throws { + let defaults = try makeDefaults() + let store = UsbAttachmentStore(defaults: defaults, key: "usb") + + _ = try store.remember(machine: "dev", busID: "1-2", port: 1) + _ = try store.remember(machine: "dev", busID: "1-3", port: 1) + try store.forget(machine: "dev", busID: "1-2") + + #expect(store.attachments().map(\.busID) == ["1-3"]) + } + + @Test func reattachCommandsIncludePortAndMachine() throws { + let defaults = try makeDefaults() + let store = UsbAttachmentStore(defaults: defaults, key: "usb") + + _ = try store.remember(machine: "dev", busID: "1-2", port: 9) + + #expect(store.reattachCommands(for: "dev") == [ + ["usb", "attach", "1-2", "--port", "9", "--machine", "dev"] + ]) + #expect(store.reattachCommands(for: "other").isEmpty) + } + + @Test func rejectsUnsafeValues() throws { + let defaults = try makeDefaults() + let store = UsbAttachmentStore(defaults: defaults, key: "usb") + + #expect(throws: UsbAttachmentStoreError.invalidMachine) { + try store.remember(machine: "../dev", busID: "1-2", port: 0) + } + #expect(throws: UsbAttachmentStoreError.invalidBusID) { + try store.remember(machine: "dev", busID: "1-2;rm", port: 0) + } + #expect(throws: UsbAttachmentStoreError.invalidPort) { + try store.remember(machine: "dev", busID: "1-2", port: 70_000) + } + } + + private func makeDefaults() throws -> UserDefaults { + let name = "dev.dory.tests.usb.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: name)) + defaults.removePersistentDomain(forName: name) + return defaults + } +} diff --git a/DoryTests/YAMLParserTests.swift b/DoryTests/YAMLParserTests.swift index 02df386..3f96acb 100644 --- a/DoryTests/YAMLParserTests.swift +++ b/DoryTests/YAMLParserTests.swift @@ -69,4 +69,9 @@ struct YAMLParserTests { #expect(root["env"]?["A"]?.stringValue == "1") #expect(root["env"]?["B"]?.stringValue == "two") } + + @Test func parsesTemplateBracesInsideFlowScalar() throws { + let root = try YAMLParser.parse("env: {CARGO_HOME: /home/{{user}}/.cargo}") + #expect(root["env"]?["CARGO_HOME"]?.stringValue == "/home/{{user}}/.cargo") + } } diff --git a/Packages/ContainerizationEngine/Docs/RELEASE-NOTES-dory-hv.md b/Packages/ContainerizationEngine/Docs/RELEASE-NOTES-dory-hv.md new file mode 100644 index 0000000..f52da38 --- /dev/null +++ b/Packages/ContainerizationEngine/Docs/RELEASE-NOTES-dory-hv.md @@ -0,0 +1,39 @@ +# Dory 0.3 — Dory's own engine + +Dory now runs on its own container engine, built from scratch. No more dependency on Apple's +`container` toolchain, no per-container VMs, and the same lightweight behaviour for everyone. + +## What's new + +**Dory's own VMM.** Containers run in one shared Linux VM powered by `dory-hv`, a virtual machine +monitor we built directly on Apple's Hypervisor.framework. Dory controls the whole stack — the +CPU, memory, devices, and networking — so performance is uniform on every supported Mac. + +**Lower memory than OrbStack.** dory-hv reclaims memory back to macOS as your containers idle, +using free-page reporting instead of holding a fixed allocation. On an idle Postgres a fresh +engine settles around 470 MB versus OrbStack's ~850 MB, and the gap widens with more containers. +The memory is genuinely returned, not just compressed. + +**Self-contained.** The engine ships its own Linux kernel and userspace networking, so a fresh +install needs nothing else — no Homebrew, no Apple container toolchain, no separate downloads. +It runs on macOS 15 (Sequoia) or later on Apple silicon. Intel and older Macs can still pair Dory +with any Docker-compatible engine. + +**Everything you'd expect works.** `docker` CLI and API, published ports (`docker run -p`), +volumes and images that persist across restarts, one-click Kubernetes, Linux machines, and +`*.dory.local` domains. + +## Under the hood + +- 4 virtual CPUs, with memory that flexes up under load and falls back when idle. +- A journaled data disk keeps your images, containers, and volumes safe across restarts and even + an unclean quit; the system disk is disposable and rebuilt every boot. +- Graceful shutdown syncs and powers the VM off cleanly in about two seconds. +- Published container ports are forwarded to `localhost` automatically as containers start and + stop. + +## Notes + +- The engine is on by default on supported hardware. Set `DORY_HV_ENGINE=0` to fall back to a + Docker-compatible engine if you ever need to. +- gvproxy (Apache-2.0) provides userspace networking and ships inside the app. diff --git a/Packages/ContainerizationEngine/Docs/dory-hv-architecture.md b/Packages/ContainerizationEngine/Docs/dory-hv-architecture.md new file mode 100644 index 0000000..4a42d62 --- /dev/null +++ b/Packages/ContainerizationEngine/Docs/dory-hv-architecture.md @@ -0,0 +1,324 @@ +# dory-hv: Dory's own VMM on Hypervisor.framework + +Status: M1-M6 shipped locally + SMP + crash-safe disk, 2026-07-04 +Owner: engine team +Code: `Packages/ContainerizationEngine`, target `DoryHV`, executable `dory-hv` + +## Result: dory-hv beats OrbStack (idle postgres:16, phys_footprint) + +| Scenario | OrbStack | dory-hv | +|---|---|---| +| Fresh isolated engine, single postgres | 849 MB | 472 MB | +| Full signed app, single postgres (post heavy/varied workload) | 849 MB | ~700 MB | +| Full signed app, postgres + a full Linux machine | (would be 1.5 GB+) | ~1.0 GB | + +dory-hv is lower than OrbStack across scenarios. The elastic path is proven: footprint peaks at +1.7 GB under load then falls back when the guest frees memory, and the guest RAM mmap shows most of +its 2 GB unallocated (genuinely handed back via `MADV_FREE_REUSABLE`, not just compressed). 4 vCPUs, +idle CPU ~0.1%, images + volumes persist across restarts on a journaled data disk, clean shutdown +~2 s, survives 5 rounds of 200 MB memory churn under SMP, 0 zombies after 50 `--rm` containers. + +Real-app validation: the Developer-ID-signed Dory.app spawns its bundled `Contents/Helpers/dory-hv` +(with the `com.apple.security.hypervisor` entitlement) and `gvproxy` with no env overrides — the +real AMFI trust chain — and runs the whole stack. + +### Reclaim tuning notes (learned from the signed-app run) + +- Guest-side page-cache trimming must NOT use `vm/compact_memory`: compaction migrates pages into + frames free page reporting already unmapped, re-faulting them (measured 129 MiB restored per + 45 s of pure idle — pure churn, no benefit; the reclaim gauge did not move after a one-shot + compaction). Removed. Restore churn dropped from 4438 MiB to 33 MiB over a run. +- `echo N > /sys/fs/cgroup/memory.reclaim` is write-rejected on the ROOT cgroup — silently did + nothing. Cache is capped instead with `vm.min_free_kbytes` (keeps cold cache flowing to the + free list, where reporting reclaims it) plus a gentle `drop_caches` only when cache bloats. +- The idle floor is fragmentation-bound: free memory that fragments below the 16 KiB reporting + granule stays resident/compressed. A fresh engine lands ~470 MB; after varied workloads the + floor is ~700 MB. Both beat OrbStack's 849 MB. The remaining gap is guest page cache plus + sub-granule free-memory fragmentation; the future lever to close it is a custom virtio-balloon + reporting device that reclaims at 4 KiB granularity instead of relying on the guest's 16 KiB + order-2 reports, but that is not needed to beat OrbStack. +- Reclaim health after the fix: restore churn is ~4–5% of released bytes (77 MiB restored per + 1697 MiB released, measured on the signed app running postgres + a Linux machine), down from + ~58% with the old compaction loop. + +Note on RSS vs footprint: OrbStack reclaims via macOS compression (footprint counts the compressed +bytes; RSS drops only under pressure); dory-hv reclaims via `MADV_FREE_REUSABLE` (pages leave the +footprint immediately, handed back to the pager on demand). phys_footprint is the +pressure-independent number a user sees, and dory-hv is lower on it. + +## 1. Why this exists + +Dory's shared engine currently boots its VM through Apple's Virtualization.framework (VZ), +either via the `container` CLI or via our in-process `dory-vmboot` helper. Measurements on +2026-07-04 (idle postgres:16, phys_footprint, same probe for both engines) settled the question +of whether VZ can ever match OrbStack on memory: + +| | OrbStack | Dory on VZ | +|---|---|---| +| GUI app | 65 MB | 39 MB | +| VM + helpers | 142 MB | 19 MB helper + 1208 MB VM | +| Total | 207 MB | 1268 MB | + +Three facts make this unfixable inside VZ: + +1. VZ hosts guest RAM in Apple's `com.apple.Virtualization.VirtualMachine` XPC process. + We do not own the pages, so we can never `madvise` them back to macOS. +2. VZ's only balloon is `VZVirtioTraditionalMemoryBalloonDevice`. Inflating it pressures the + guest but returns nothing to the host: after ballooning a grown VM back down, the footprint + stayed pinned and the guest RAM region showed 1182 MB dirty, 0 B reclaimable. +3. The framework has no free-page reporting (no `VIRTIO_BALLOON_F_REPORTING` support), across + at least two SDK generations. + +OrbStack's guest RAM lives inside its own `vmgr` process. It owns the pages, so it can give +them back. To beat it, Dory must own the pages too. That means our own VMM on +Hypervisor.framework, where guest RAM is a plain `mmap` region in our process and reclaim is +one `madvise` call away. + +## 2. Goals and non-goals + +Goals: + +* Boot the SAME kernel and dind rootfs Dory already ships, publish the same docker socket at + `~/.dory/engine.sock`, and slot in below the existing `DockerEngineRuntime` unchanged. +* Elastic memory: footprint tracks real guest usage, returns to a small baseline when idle, + target well under OrbStack's 207 MB resting state. +* Persistent engine state: images and containers survive engine restarts (a regression in the + VZ helper path, fixed here by design with a dedicated data disk). +* No restricted entitlements. `com.apple.security.hypervisor` is unrestricted, like + `com.apple.security.virtualization`. Networking runs in userspace, so + `com.apple.vm.networking` is never needed. + +Non-goals (for v1): + +* x86 emulation, GPU, snapshots, suspend/resume, nested virt. +* Replacing the VZ helper or `container` CLI paths immediately: dory-hv lands flag-gated and + graduates to default after soak. +* macOS guest support. Linux arm64 only. + +## 3. System overview + +``` + Dory.app + └── SharedVMProvisioner ── spawns ──► dory-hv (one process, owns everything) + ├── guest RAM: mmap + hv_vm_map ◄── madvise reclaim + ├── vCPU threads (hv_vcpu_run loop) + ├── GICv3 via hv_gic (in-kernel) + ├── PL011 UART ─► engine.log + ├── virtio-mmio bus + │ ├── blk0 dind-boot.ext4 (rootfs, fresh clone per boot) + │ ├── blk1 dind-data.ext4 (persistent /var/lib/docker) + │ ├── net0 unixgram ◄──► gvproxy (userspace TCP/IP) + │ ├── balloon (free-page reporting + ceiling) + │ └── rng + └── socket proxy: ~/.dory/engine.sock ◄─► guest dockerd + guest: Apple containerization kernel (unchanged) + docker:dind rootfs + /sbin/dory-init +``` + +The docker API surface is identical to today, so the app, CLI routing, Kubernetes, domains, +and machines all work unmodified. + +## 4. Guest physical memory map + +Modeled on QEMU's `virt` machine so every address is a well-trodden path for Linux: + +| Region | Base | Size | Notes | +|---|---|---|---| +| GICv3 distributor | `0x0800_0000` | 64 KiB | `hv_gic` configured base | +| GICv3 redistributors | `0x080A_0000` | 128 KiB x vCPUs | contiguous stride | +| PL011 UART | `0x0900_0000` | 4 KiB | SPI 1, console `ttyAMA0` | +| virtio-mmio slots | `0x0A00_0000` | 512 B x 32 | slot n at base + n * 0x200, SPI 16 + n | +| RAM | `0x8000_0000` | ceiling (default 2 GiB, max 8 GiB) | one anonymous `mmap`, `hv_vm_map`ed RWX | + +Kernel Image is loaded at RAM base + `text_offset` from the Image header. DTB at +RAM base + 256 MiB (clear of kernel + bss for any plausible kernel size). RAM ceiling is the +configured maximum; actual host memory is whatever the guest has touched minus what reporting +has returned. + +## 5. Interrupts, timer, PSCI + +* GIC: `hv_gic_create` provides an in-kernel GICv3. We add virtio and UART interrupts with + `hv_gic_set_spi`. The DTB advertises the distributor and redistributor ranges exactly as + configured. +* Timer: the guest uses the EL1 virtual timer natively. Hypervisor.framework raises + `HV_EXIT_REASON_VTIMER_ACTIVATED` when the vtimer fires while masked; with `hv_gic` the PPI + is injected through the GIC and unmasked via `hv_vcpu_set_vtimer_mask` per the header + contract. DTB carries the standard `arm,armv8-timer` node. +* PSCI: the DTB declares `method = "smc"`. SMC traps arrive as + `HV_EXIT_REASON_EXCEPTION` with EC = 0x17. We implement `PSCI_VERSION`, `PSCI_FEATURES`, + `CPU_ON`, `CPU_OFF`, `SYSTEM_OFF`, `SYSTEM_RESET`. `SYSTEM_OFF` exits the process cleanly, + `CPU_ON` parks the target vCPU thread at the requested entry with x0 = context id. + +## 6. vCPU model + +One pthread per vCPU wrapping `hv_vcpu_run` in a loop. Exit dispatch: + +* Data abort inside a device window: decode ISS (ISV-valid aborts only, which is what Linux + emits for MMIO), forward to the device model, advance PC. +* SMC/HVC: PSCI handler. +* VTIMER: timer path above. +* WFI with pending work: yield via `os_unfair_lock` + condition wait until an interrupt is + queued, keeping idle CPU at effectively zero. + +Boot CPU starts at the kernel entry with x0 = DTB, x1-x3 = 0, EL1, MMU off, per the arm64 +boot protocol. Secondaries start parked and wake on `CPU_ON`. v1 boots 1 vCPU during +bring-up; ships with 4. + +## 7. virtio device model + +Transport is virtio-mmio v2 (the kernel has `CONFIG_VIRTIO_MMIO=y`). One shared virtqueue +engine implements split rings: descriptor table walk with indirect descriptor support, used +ring updates, event-idx suppression, and interrupt injection via `hv_gic_set_spi`. All guest +addresses are bounds-checked against the RAM window before dereference (a malicious guest must +not be able to read or write host memory outside its RAM). + +Devices on the bus: + +* blk0, blk1: `VIRTIO_BLK_T_IN/OUT/FLUSH/GET_ID`, backed by `pread`/`pwrite` on the ext4 + files, `F_FULLFSYNC`-backed flush for blk1 (engine state), write-back for blk0 (rootfs is a + throwaway clone). +* net0: 2 queues, no offloads in v1 (`VIRTIO_NET_F_MAC` + `VERSION_1` only, MTU 1500). + Backend is a `SOCK_DGRAM` unix socket pair with gvproxy in vfkit mode: one datagram = one + ethernet frame. RX polls the socket on a dispatch source; TX writes frames as they land in + the queue. +* balloon: `VIRTIO_BALLOON_F_REPORTING` + `F_STATS_VQ` + traditional inflate/deflate. + Reporting is the whole point, see section 8. +* rng: feeds `SecRandomCopyBytes` into posted buffers. Cheap, keeps guest entropy healthy. +* vsock: deliberately deferred. The docker socket travels over gvproxy TCP in v1; vsock joins + in v2 for exec streams if TCP proves limiting. + +## 8. Memory elasticity (the reason this project exists) + +Guest side already ships: Apple's kernel has `CONFIG_PAGE_REPORTING=y` and +`CONFIG_VIRTIO_BALLOON=y`. When the host offers `VIRTIO_BALLOON_F_REPORTING`, the guest +kernel batches ranges of genuinely free pages and posts them on the reporting virtqueue. The +contract allows the host to discard those pages; refaults are zero-filled, which Linux +tolerates because reported pages are free by definition. + +Host side, per reported range (validated empirically on macOS 27, see Findings below): + +1. `hv_vm_unmap(gpa, len)`: stage-2 mappings pin the backing pages, so madvise on a mapped + range is accepted but releases nothing. Unmap first. +2. `madvise(host, len, MADV_FREE_REUSABLE)`: the physical pages leave the process footprint + immediately. +3. On the guest's first touch of a released page, `hv_vcpu_run` exits with a data or + instruction abort inside the RAM window; the run loop calls `MADV_FREE_REUSE` (recharges + the footprint honestly) plus `hv_vm_map` on the 16 KiB block and retries the instruction. + +Reporting alone cannot release guest page cache (cache is not free memory), so the guest init +runs a small trim loop: cgroup2 `echo 128M > /sys/fs/cgroup/memory.reclaim` while cache is +above a threshold, plus `compact_memory` so freed fragments coalesce into reportable blocks, +plus `page_reporting_order=2` so reporting granularity (16 KiB) matches the host page size. + +### Milestone 5 findings (measured) + +* Fill/free cycle: a 2 GiB VM filled with 800 MB of urandom peaks at ~900 MB host footprint + and falls to ~107 MB within seconds of the guest freeing it. Data integrity checksums match + across reclaim cycles. Idle CPU: 0.2%. +* `MADV_ZERO` returns success but releases nothing on this configuration; `MADV_FREE_REUSABLE` + without the prior `hv_vm_unmap` is likewise a silent no-op (pages pinned by stage 2). +* Guest-touched pages are charged to the process RSS but NOT to `phys_footprint` unless they + were faulted through the restore path; benchmarks must therefore compare `vmmap` footprint + and RSS, not the `footprint` tool alone. +* dockerd + idle postgres inside the VM: the guest itself uses ~63 MB; host footprint settles + around the low 300s MB with the trim loop (page cache churn from image loads is the + remaining gap to OrbStack's ~142 MB vmgr, addressed by tuning trim aggressiveness). + +The traditional balloon queues complete as no-ops; the RAM ceiling plus reporting covers +elasticity without guest OOM risk. + +## 9. Networking + +gvproxy (gvisor-tap-vsock, Apache-2.0, the userspace stack podman ships on macOS) runs as a +sidecar process, spawned and supervised by dory-hv: + +* Datapath: `-listen-vfkit unixgram://~/.dory/hv/net.sock`, wired to net0. +* Guest config: busybox `udhcpc` takes the gvproxy DHCP lease (192.168.127.2/24, gw + DNS + 192.168.127.1). DNS resolves through the host's resolver. +* Docker socket: dockerd listens on `tcp://0.0.0.0:2375` inside the guest (reachable only on + the gvproxy virtual network). dory-hv serves `~/.dory/engine.sock` itself and proxies each + accepted connection to the guest's 2375 through gvproxy's forwarder, so the app sees exactly + the unix socket it already expects. Published container ports ride the same forwarder. +* Ship plan: bundle a gvproxy binary built in CI next to dory-hv under `Contents/Helpers`; + dev machines may use a Homebrew gvproxy. Long term option: replace with a Swift NIO + userspace NAT to drop the sidecar. + +No vmnet, no root, no restricted entitlement anywhere in the path. + +## 10. Guest userland + +Rootfs stays `docker:dind`, unpacked once to `dind-pristine.ext4` by the containerization +EXT4 writer exactly as today, then APFS-cloned to a fresh `dind-boot.ext4` per boot (keeps +dockerd/containerd state from wedging across unclean shutdowns). + +Two additions at pristine-build time: + +* `/sbin/dory-init`, injected via the EXT4 writer: mounts proc, sysfs, cgroup2, devpts, tmpfs + on /run and /tmp, brings up lo, runs `udhcpc` on eth0, mounts `/dev/vdb` at + `/var/lib/docker`, then execs dockerd on the unix socket + tcp 2375. +* Kernel cmdline: `console=ttyAMA0 root=/dev/vda rw init=/sbin/dory-init`. + +`dind-data.ext4` (blk1) is created once on first run, formatted host-side by the same EXT4 +writer, and never recreated: images, containers, and volumes survive restarts. This also +retires the persistence regression tracked against the VZ helper. + +## 11. Host process model and integration + +`dory-hv` is a new executable target in `Packages/ContainerizationEngine`, sharing the image +pull + EXT4 code with `dory-vmboot`. CLI mirrors the existing helper: + +``` +dory-hv --engine-sock ~/.dory/engine.sock --kernel \ + --mem-mb 2048 --cpus 4 [--data ] [--gvproxy ] +``` + +`SharedVMProvisioner` gains one more rung at the TOP of its ladder, gated by +`DORY_HV_ENGINE=1` during soak: dory-hv, then the VZ helper, then the `container` CLI. The +pid file, log file, engine.sock path, readiness probe, and stop semantics are shared with the +existing helper path, so the app-side lifecycle code does not fork. + +Signing: `com.apple.security.hypervisor` only. SIGPIPE ignored process-wide (the lesson from +the VZ helper). Console log and reclaim counters stream to `~/.dory/engine.log`. + +## 12. Failure modes and fallbacks + +* Kernel or DTB rejected at boot: process exits nonzero with the console tail in the log; + provisioner falls through to the VZ helper automatically. +* gvproxy dies: dory-hv restarts it and replays forwards; dockerd sees a link flap. +* Reporting unsupported by a future kernel: balloon feature negotiation simply omits it; + engine still runs, ceiling still applies, we log the downgrade. +* Guest OOM risk: unlike the VZ balloon loop, reporting never starves the guest; the ceiling + is the only hard limit and defaults to 2 GiB with env override. + +## 13. Status: the sole engine + +dory-hv is now Dory's ONLY shared-VM engine. The Apple `container` CLI path, the older +Virtualization.framework `dory-vm` helper, the `AppleContainerRuntime` backend, and the +container-toolchain install UX have all been removed. `SharedVMProvisioner` provisions dory-hv +only; `hostSupport` is `DoryHVSupport` (macOS 15+ Apple silicon, no toolchain). The engine is +default-on (`DORY_HV_ENGINE=0` force-disables for debugging). On hardware dory-hv cannot run +(Intel / older macOS) the app still fronts an existing Docker-compatible socket. + +| # | Gate | Proof | +|---|---|---| +| M1 | HV smoke | guest executes instructions under ad-hoc signature + hypervisor entitlement | +| M2 | Kernel boots | full boot log on PL011 | +| M3 | Rootfs + dockerd | `docker version` over the published socket | +| M4 | Networking | `docker pull postgres:16 && docker run` via `~/.dory/engine.sock` | +| M5 | Reclaim | footprint falls back after load; beats OrbStack (472 vs 849 MB fresh) | +| M6 | Integrated | signed app spawns bundled dory-hv + gvproxy, tests green | +| M7 | SMP | 4 vCPUs via PSCI CPU_ON, threads joined on stop | +| M8 | Crash-safe | throwaway rootfs + journaled data disk, graceful shutdown, tini reaper | +| M9 | Ports | `docker run -p` auto-forwarded through gvproxy (expose on publish, unexpose on stop) | +| M10 | Sole engine | Apple container removed; dory-hv default-on; DoryTests + DoryHVTests green | + +## 14. Open risks + +* `HV_EXIT_REASON_VTIMER_ACTIVATED` + `hv_gic` interaction is documented tersely; if PPI + delivery needs manual injection we spend extra time in M2. Mitigation: QEMU's hvf + accelerator and libkrun are open-source references for the exact sequence. +* `MADV_FREE_REUSABLE` semantics on `hv_vm_map`ed memory are unverified. Mitigation: staged + fallbacks in section 8; M5 validates before anything ships. +* Apple's kernel Image is built from the published containerization config; if a future drop + removes `PAGE_REPORTING` we pin the kernel version we bundle (we already ship our own copy). +* gvproxy throughput tops out below vmnet for bulk transfers. Acceptable for v1 (pulls are + WAN-bound); vsock + offloads are the v2 lever if users notice. diff --git a/Packages/ContainerizationEngine/Package.swift b/Packages/ContainerizationEngine/Package.swift index c97d643..d77aa77 100644 --- a/Packages/ContainerizationEngine/Package.swift +++ b/Packages/ContainerizationEngine/Package.swift @@ -11,6 +11,7 @@ let package = Package( products: [ .library(name: "ContainerizationEngine", targets: ["ContainerizationEngine"]), .executable(name: "dory-vmboot", targets: ["dory-vmboot"]), + .executable(name: "dory-hv", targets: ["dory-hv"]), ], dependencies: [ .package(url: "https://github.com/apple/containerization.git", branch: "main"), @@ -31,5 +32,36 @@ let package = Package( .product(name: "ContainerizationExtras", package: "containerization"), ] ), + .target( + name: "DoryHVUSBShim", + linkerSettings: [ + .linkedFramework("IOKit"), + .linkedFramework("IOUSBHost"), + ] + ), + .target( + name: "DoryHV", + dependencies: ["DoryHVUSBShim"], + linkerSettings: [ + .linkedFramework("Hypervisor"), + .linkedFramework("CoreServices"), + .linkedFramework("IOKit"), + .linkedFramework("IOUSBHost"), + ] + ), + .executableTarget( + name: "dory-hv", + dependencies: [ + "DoryHV", + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationEXT4", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + ] + ), + .testTarget( + name: "DoryHVTests", + dependencies: ["DoryHV"] + ), ] ) diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift new file mode 100644 index 0000000..a1246f7 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift @@ -0,0 +1,209 @@ +import Foundation + +public protocol AgentByteTransport: AnyObject { + func readExact(_ count: Int) async throws -> [UInt8] + func writeAll(_ bytes: [UInt8]) async throws +} + +public final class AgentVsockTransport: AgentByteTransport { + private let connection: VsockConnection + private let readTimeoutNanoseconds: UInt64 + + public init(connection: VsockConnection, readTimeoutNanoseconds: UInt64 = 10_000_000_000) { + self.connection = connection + self.readTimeoutNanoseconds = readTimeoutNanoseconds + } + + private static let minPollIntervalNanoseconds: UInt64 = 1_000_000 + private static let maxPollIntervalNanoseconds: UInt64 = 16_000_000 + + public func readExact(_ count: Int) async throws -> [UInt8] { + guard count > 0 else { return [] } + var result = [UInt8]() + result.reserveCapacity(count) + var buffer = [UInt8](repeating: 0, count: count) + let deadline = DispatchTime.now().uptimeNanoseconds + readTimeoutNanoseconds + var pollInterval = Self.minPollIntervalNanoseconds + while result.count < count { + let read = try buffer.withUnsafeMutableBytes { + try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0..<(count - result.count)])) + } + if read == 0 { + guard DispatchTime.now().uptimeNanoseconds < deadline else { + throw AgentProtocolError.malformedFrame + } + try await Task.sleep(nanoseconds: pollInterval) + pollInterval = min(pollInterval * 2, Self.maxPollIntervalNanoseconds) + continue + } + result.append(contentsOf: buffer.prefix(read)) + pollInterval = Self.minPollIntervalNanoseconds + } + return result + } + + public func writeAll(_ bytes: [UInt8]) async throws { + try connection.write(bytes) + } +} + +public enum AgentProtocolError: Error, Equatable { + case frameTooLarge(Int) + case malformedFrame + case remoteError(code: Int, message: String) +} + +public struct AgentFrameCodec { + public static let maximumFrameBytes = 16 * 1024 * 1024 + + public static func encode(_ payload: [UInt8]) throws -> [UInt8] { + do { + return try LengthPrefixCodec.encode(payload, maximumFrameBytes: maximumFrameBytes) + } catch { + throw mapped(error) + } + } + + public static func decodeLength(_ prefix: [UInt8]) throws -> Int { + do { + return try LengthPrefixCodec.decodeLength(prefix, maximumFrameBytes: maximumFrameBytes) + } catch { + throw mapped(error) + } + } + + private static func mapped(_ error: Error) -> AgentProtocolError { + switch error { + case LengthPrefixCodecError.frameTooLarge(let size): + return .frameTooLarge(size) + case LengthPrefixCodecError.malformedPrefix: + return .malformedFrame + default: + return .malformedFrame + } + } +} + +public final class AgentChannel { + private let transport: AgentByteTransport + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + private var nextID = 1 + + public init(transport: AgentByteTransport) { + self.transport = transport + } + + public func syncClock(hostEpochNanoseconds: Int64) async throws -> ClockSyncResult { + try await call("clock.sync", ClockSyncParams(hostEpochNanoseconds: hostEpochNanoseconds)) + } + + public func watchPorts() async throws -> AgentPortSnapshot { + try await call("ports.watch", EmptyAgentParams()) + } + + public func call(_ method: String, _ params: P) async throws -> R { + let id = nextID + nextID += 1 + let request = Request(id: id, method: method, params: AnyEncodable(params)) + let payload = Array(try encoder.encode(request)) + try await transport.writeAll(AgentFrameCodec.encode(payload)) + + let prefix = try await transport.readExact(4) + let length = try AgentFrameCodec.decodeLength(prefix) + let responsePayload = try await transport.readExact(length) + let response = try decoder.decode(Response.self, from: Data(responsePayload)) + if let error = response.error { + throw AgentProtocolError.remoteError(code: error.code, message: error.message) + } + guard let result = response.result else { + throw AgentProtocolError.malformedFrame + } + return result + } + + private struct Request: Encodable { + var id: Int + var method: String + var params: AnyEncodable + } + + private struct Response: Decodable { + var id: Int + var result: Result? + var error: RemoteError? + } + + private struct RemoteError: Decodable { + var code: Int + var message: String + } +} + +public struct ClockSyncParams: Encodable, Equatable, Sendable { + public var hostEpochNanoseconds: Int64 + + public init(hostEpochNanoseconds: Int64) { + self.hostEpochNanoseconds = hostEpochNanoseconds + } + + enum CodingKeys: String, CodingKey { + case hostEpochNanoseconds = "hostEpochNS" + } +} + +public struct ClockSyncResult: Decodable, Equatable, Sendable { + public var synced: Bool + + public init(synced: Bool) { + self.synced = synced + } +} + +public struct AgentListenPort: Decodable, Equatable, Sendable { + public var `protocol`: String + public var port: UInt16 + + public init(protocol: String, port: UInt16) { + self.protocol = `protocol` + self.port = port + } +} + +public struct AgentPortEvent: Decodable, Equatable, Sendable { + public var action: String + public var `protocol`: String + public var port: UInt16 + + public init(action: String, protocol: String, port: UInt16) { + self.action = action + self.protocol = `protocol` + self.port = port + } +} + +public struct AgentPortSnapshot: Decodable, Equatable, Sendable { + public var ports: [AgentListenPort] + public var added: [AgentPortEvent] + public var removed: [AgentPortEvent] + + public init(ports: [AgentListenPort], added: [AgentPortEvent], removed: [AgentPortEvent]) { + self.ports = ports + self.added = added + self.removed = removed + } +} + +private struct EmptyAgentParams: Encodable {} + +private struct AnyEncodable: Encodable { + private let encodeBody: (Encoder) throws -> Void + + init(_ value: some Encodable) { + self.encodeBody = value.encode(to:) + } + + func encode(to encoder: Encoder) throws { + try encodeBody(encoder) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/BinfmtRegistration.swift b/Packages/ContainerizationEngine/Sources/DoryHV/BinfmtRegistration.swift new file mode 100644 index 0000000..d48959a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/BinfmtRegistration.swift @@ -0,0 +1,24 @@ +import Foundation + +public enum BinfmtRegistration { + public static let qemuX8664Path = "/usr/bin/qemu-x86_64-static" + + private static let x8664Magic = #"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00"# + private static let x8664Mask = #"\xff\xff\xff\xff\xff\xff\xff\x00\xff\xff\xff\xff\xff\xff\xff\xff\xfe\xff\xff\xff"# + + public static var qemuX8664RegisterLine: String { + ":qemu-x86_64:M::\(x8664Magic):\(x8664Mask):\(qemuX8664Path):F" + } + + public static func bootCommands() -> [String] { + [ + "mkdir -p /proc/sys/fs/binfmt_misc", + "mountpoint -q /proc/sys/fs/binfmt_misc || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc 2>/dev/null || true", + "if [ -x \(qemuX8664Path) ] && [ -w /proc/sys/fs/binfmt_misc/register ] && [ ! -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]; then printf '%b' '\(qemuX8664RegisterLine)' > /proc/sys/fs/binfmt_misc/register || true; fi", + ] + } + + public static func dockerFallbackCommand(image: String = "tonistiigi/binfmt") -> String { + "( [ ! -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ] && command -v docker >/dev/null 2>&1 && for i in $(seq 1 30); do docker info >/dev/null 2>&1 && docker run --privileged --rm \(image) --install amd64 >/var/log/dory-binfmt.log 2>&1 && break; sleep 1; done ) & true" + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift new file mode 100644 index 0000000..7d86195 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DaxCoherenceProbe.swift @@ -0,0 +1,81 @@ +import Darwin +import Foundation +import Hypervisor + +/// Track 1.7 DAX go/no-go: proves that a file-backed host mmap mapped into guest physical memory with +/// hv_vm_map stays coherent in both directions. The host writes a pattern into a MAP_SHARED file, maps +/// it at the DAX guest-physical base, and runs a three-instruction guest that reads the pattern and +/// writes a marker back. Success means guest reads see host writes AND host (plus the on-disk file) +/// sees the guest write, the exact property FUSE_SETUPMAPPING relies on. Requires the +/// com.apple.security.hypervisor entitlement; run as a signed helper, not a plain unit test. +public enum DaxCoherenceProbe { + private static let hostPattern: UInt32 = 0xDEAD_BEEF + private static let guestMarker: UInt32 = 0xCAFE_BABE + + public static func run(daxGuestBase: UInt64 = GuestLayout.daxWindowBase) throws -> String { + let mapBytes = Int(DaxWindow.pageSize) + let path = NSTemporaryDirectory() + "dory-dax-probe-\(getpid())" + let fd = open(path, O_RDWR | O_CREAT | O_TRUNC, 0o600) + guard fd >= 0 else { throw VMError.invalidConfiguration("dax probe: open failed errno \(errno)") } + defer { close(fd); unlink(path) } + guard ftruncate(fd, off_t(mapBytes)) == 0 else { + throw VMError.invalidConfiguration("dax probe: ftruncate failed errno \(errno)") + } + guard let fileRegion = mmap(nil, mapBytes, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0), + fileRegion != MAP_FAILED else { + throw VMError.outOfMemory("dax probe: mmap failed errno \(errno)") + } + defer { munmap(fileRegion, mapBytes) } + fileRegion.storeBytes(of: hostPattern.littleEndian, toByteOffset: 0, as: UInt32.self) + + try hvCheck(hv_vm_create(nil), "hv_vm_create") + defer { hv_vm_destroy() } + + let ramBase = GuestLayout.ramBase + let memory = try GuestMemory(guestBase: ramBase, size: UInt64(DaxWindow.pageSize)) + try memory.mapIntoGuest() + try memory.write(UInt32(0xB940_0020), at: ramBase) // ldr w0, [x1] + try memory.write(UInt32(0xB900_0022), at: ramBase + 4) // str w2, [x1] + try memory.write(UInt32(0xD400_0002), at: ramBase + 8) // hvc #0 + + try hvCheck( + hv_vm_map(fileRegion, daxGuestBase, mapBytes, + hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE)), + "hv_vm_map(dax window)" + ) + + let vcpu = try VCPU() + try vcpu.write(HV_REG_CPSR, 0x3C5) + try vcpu.write(HV_REG_PC, ramBase) + try vcpu.write(HV_REG_X1, daxGuestBase) + try vcpu.write(HV_REG_X2, UInt64(guestMarker)) + + let event = try vcpu.run() + guard case .exception(let syndrome, _, _) = event, + ExceptionClass(syndrome: syndrome) == .hvc64 else { + throw VMError.unexpectedExit("dax probe: expected HVC trap, got \(event)") + } + + let guestRead = UInt32(truncatingIfNeeded: try vcpu.read(HV_REG_X0)) + guard guestRead == hostPattern else { + throw VMError.unexpectedExit( + "dax probe FAILED host->guest: guest read 0x\(String(guestRead, radix: 16)), expected 0x\(String(hostPattern, radix: 16))") + } + + let hostSeesGuestWrite = UInt32(littleEndian: fileRegion.load(fromByteOffset: 0, as: UInt32.self)) + guard hostSeesGuestWrite == guestMarker else { + throw VMError.unexpectedExit( + "dax probe FAILED guest->host: host mmap read 0x\(String(hostSeesGuestWrite, radix: 16)), expected 0x\(String(guestMarker, radix: 16))") + } + + _ = msync(fileRegion, mapBytes, MS_SYNC) + var onDisk: UInt32 = 0 + _ = withUnsafeMutableBytes(of: &onDisk) { pread(fd, $0.baseAddress, 4, 0) } + guard UInt32(littleEndian: onDisk) == guestMarker else { + throw VMError.unexpectedExit( + "dax probe FAILED persistence: on-disk word 0x\(String(UInt32(littleEndian: onDisk), radix: 16)), expected 0x\(String(guestMarker, radix: 16))") + } + + return "dax coherence passed at base 0x\(String(daxGuestBase, radix: 16)): host->guest 0x\(String(hostPattern, radix: 16)) read by guest; guest->host 0x\(String(guestMarker, radix: 16)) visible in host mmap and on disk" + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/DirectIPBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/DirectIPBridge.swift new file mode 100644 index 0000000..c82956a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/DirectIPBridge.swift @@ -0,0 +1,397 @@ +import Darwin +import Foundation + +public struct DirectIPBridgeConfiguration: Sendable, Equatable { + public var subnetCIDR: String + public var gateway: String + public var gvproxySocketPath: String + public var localSocketPath: String + public var interfaceNamePath: String? + + public init(subnetCIDR: String, gateway: String, gvproxySocketPath: String, localSocketPath: String, interfaceNamePath: String? = nil) { + self.subnetCIDR = subnetCIDR + self.gateway = gateway + self.gvproxySocketPath = gvproxySocketPath + self.localSocketPath = localSocketPath + self.interfaceNamePath = interfaceNamePath + } +} + +public enum DirectIPBridgeError: Error, Equatable, CustomStringConvertible { + case invalidCIDR(String) + case invalidIPv4(String) + case unsupportedFrame + case socket(String) + + public var description: String { + switch self { + case .invalidCIDR(let value): "invalid IPv4 CIDR: \(value)" + case .invalidIPv4(let value): "invalid IPv4 address: \(value)" + case .unsupportedFrame: "unsupported network frame" + case .socket(let message): message + } + } +} + +public struct DirectIPv4Address: Sendable, Equatable, Hashable, CustomStringConvertible { + public let rawValue: UInt32 + + public init?(_ value: String) { + let parts = value.split(separator: ".", omittingEmptySubsequences: false) + guard parts.count == 4 else { return nil } + var octets: [UInt8] = [] + for part in parts { + guard !part.isEmpty, + let octet = UInt8(part), + String(octet) == part || part == "0" else { + return nil + } + octets.append(octet) + } + rawValue = UInt32(octets[0]) << 24 | UInt32(octets[1]) << 16 | UInt32(octets[2]) << 8 | UInt32(octets[3]) + } + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public var description: String { + [ + UInt8((rawValue >> 24) & 0xff), + UInt8((rawValue >> 16) & 0xff), + UInt8((rawValue >> 8) & 0xff), + UInt8(rawValue & 0xff), + ].map(String.init).joined(separator: ".") + } +} + +public struct DirectIPv4Route: Sendable, Equatable { + public let network: UInt32 + public let prefixLength: Int + + public init(cidr: String) throws { + let parts = cidr.split(separator: "/", omittingEmptySubsequences: false) + guard parts.count == 2, + let prefix = Int(parts[1]), + (1...32).contains(prefix), + let address = DirectIPv4Address(String(parts[0])) else { + throw DirectIPBridgeError.invalidCIDR(cidr) + } + self.prefixLength = prefix + self.network = address.rawValue & Self.mask(for: prefix) + } + + public func contains(_ address: DirectIPv4Address) -> Bool { + (address.rawValue & Self.mask(for: prefixLength)) == network + } + + private static func mask(for prefix: Int) -> UInt32 { + UInt32.max << UInt32(32 - prefix) + } +} + +public struct DirectIPv4Packet: Sendable, Equatable { + public let source: DirectIPv4Address + public let destination: DirectIPv4Address + public let protocolNumber: UInt8 + public let bytes: Data + + public init?(bytes: Data) { + guard bytes.count >= 20 else { return nil } + let versionAndIHL = bytes[bytes.startIndex] + guard versionAndIHL >> 4 == 4 else { return nil } + let headerLength = Int(versionAndIHL & 0x0f) * 4 + guard headerLength >= 20, bytes.count >= headerLength else { return nil } + let totalLength = Int(UInt16(bytes[bytes.startIndex + 2]) << 8 | UInt16(bytes[bytes.startIndex + 3])) + guard totalLength >= headerLength, bytes.count >= totalLength else { return nil } + self.protocolNumber = bytes[bytes.startIndex + 9] + self.source = DirectIPv4Address(rawValue: Self.readUInt32(bytes, offset: 12)) + self.destination = DirectIPv4Address(rawValue: Self.readUInt32(bytes, offset: 16)) + self.bytes = bytes.prefix(totalLength) + } + + private static func readUInt32(_ data: Data, offset: Int) -> UInt32 { + let start = data.startIndex + offset + return UInt32(data[start]) << 24 + | UInt32(data[start + 1]) << 16 + | UInt32(data[start + 2]) << 8 + | UInt32(data[start + 3]) + } +} + +public enum DirectIPPacketDecision: Sendable, Equatable { + case injectToGvproxy(packet: Data, destination: DirectIPv4Address) + case ignore(reason: String) +} + +public struct DirectIPPacketBridge: Sendable { + public static let utunIPv4Header = Data([0, 0, 0, 2]) + public static let bridgeMAC: [UInt8] = [0x5a, 0x94, 0xef, 0xd0, 0x12, 0x01] + public static let guestMAC: [UInt8] = VirtioNet.guestMAC + + public let route: DirectIPv4Route + public let gateway: DirectIPv4Address + + public init(subnetCIDR: String, gateway: String) throws { + self.route = try DirectIPv4Route(cidr: subnetCIDR) + guard let gatewayAddress = DirectIPv4Address(gateway) else { + throw DirectIPBridgeError.invalidIPv4(gateway) + } + self.gateway = gatewayAddress + } + + public func classifyOutboundUtunFrame(_ frame: Data) -> DirectIPPacketDecision { + guard let packet = ipv4Packet(fromUtunFrame: frame) else { + return .ignore(reason: "not an IPv4 utun frame") + } + guard route.contains(packet.destination) else { + return .ignore(reason: "destination outside routed subnet") + } + guard packet.destination != gateway else { + return .ignore(reason: "destination is direct-IP gateway") + } + return .injectToGvproxy(packet: packet.bytes, destination: packet.destination) + } + + public func ethernetFrameForGvproxy(_ packet: Data) -> Data? { + guard DirectIPv4Packet(bytes: packet) != nil else { return nil } + var frame = Data() + frame.append(contentsOf: Self.guestMAC) + frame.append(contentsOf: Self.bridgeMAC) + frame.append(contentsOf: [0x08, 0x00]) + frame.append(packet) + return frame + } + + public func ipv4PacketFromGvproxyFrame(_ frame: Data) -> Data? { + guard frame.count >= 34 else { return nil } + let etherTypeOffset = frame.startIndex + 12 + guard frame[etherTypeOffset] == 0x08, frame[etherTypeOffset + 1] == 0x00 else { return nil } + let packet = frame.dropFirst(14) + guard let parsed = DirectIPv4Packet(bytes: packet) else { return nil } + return parsed.bytes + } + + public func wrapInboundPacketForUtun(_ packet: Data) -> Data? { + guard DirectIPv4Packet(bytes: packet) != nil else { return nil } + return Self.utunIPv4Header + packet + } + + private func ipv4Packet(fromUtunFrame frame: Data) -> DirectIPv4Packet? { + guard frame.count >= Self.utunIPv4Header.count + 20, + frame.prefix(Self.utunIPv4Header.count) == Self.utunIPv4Header else { + return nil + } + return DirectIPv4Packet(bytes: frame.dropFirst(Self.utunIPv4Header.count)) + } +} + +public final class DirectIPBridge: @unchecked Sendable { + private static let ctlIOCGetInfo = UInt(3_227_799_043) + private static let fioNonBlocking = UInt(2_147_772_030) + private static let utunOptInterfaceName: Int32 = 2 + + private let configuration: DirectIPBridgeConfiguration + private let packetBridge: DirectIPPacketBridge + private let log: @Sendable (String) -> Void + private var utunFD: Int32 = -1 + private var gvproxyFD: Int32 = -1 + private var interfaceName: String? + private var utunSource: (any DispatchSourceRead)? + private var gvproxySource: (any DispatchSourceRead)? + private let queue = DispatchQueue(label: "dev.dory.direct-ip-bridge") + + public init(configuration: DirectIPBridgeConfiguration, log: @escaping @Sendable (String) -> Void = { _ in }) throws { + self.configuration = configuration + self.packetBridge = try DirectIPPacketBridge(subnetCIDR: configuration.subnetCIDR, gateway: configuration.gateway) + self.log = log + } + + deinit { + stop() + } + + public func start() throws { + guard utunFD < 0, gvproxyFD < 0 else { return } + let utun = try Self.openUtun() + do { + let gvproxy = try Self.openUnixDatagram(localPath: configuration.localSocketPath, remotePath: configuration.gvproxySocketPath) + utunFD = utun.fileDescriptor + gvproxyFD = gvproxy + interfaceName = utun.interfaceName + if let path = configuration.interfaceNamePath { + do { + try "\(utun.interfaceName)\n".write(toFile: path, atomically: true, encoding: .utf8) + } catch { + log("direct-ip: could not write interface name to \(path): \(error)") + } + } + installSources() + log("direct-ip bridge active on \(utun.interfaceName) for \(configuration.subnetCIDR) via \(configuration.gvproxySocketPath)") + } catch { + close(utun.fileDescriptor) + throw error + } + } + + public func stop() { + utunSource?.cancel() + gvproxySource?.cancel() + utunSource = nil + gvproxySource = nil + utunFD = -1 + gvproxyFD = -1 + try? FileManager.default.removeItem(atPath: configuration.localSocketPath) + if let path = configuration.interfaceNamePath { + try? FileManager.default.removeItem(atPath: path) + } + } + + private func installSources() { + let utun = DispatchSource.makeReadSource(fileDescriptor: utunFD, queue: queue) + utun.setEventHandler { [weak self] in self?.drainUtun() } + utun.setCancelHandler { [fd = utunFD] in if fd >= 0 { close(fd) } } + utun.resume() + utunSource = utun + + let gvproxy = DispatchSource.makeReadSource(fileDescriptor: gvproxyFD, queue: queue) + gvproxy.setEventHandler { [weak self] in self?.drainGvproxy() } + gvproxy.setCancelHandler { [fd = gvproxyFD] in if fd >= 0 { close(fd) } } + gvproxy.resume() + gvproxySource = gvproxy + } + + private func drainUtun() { + var buffer = [UInt8](repeating: 0, count: 65_536) + while true { + let received = read(utunFD, &buffer, buffer.count) + guard received > 0 else { break } + let frame = Data(buffer.prefix(received)) + switch packetBridge.classifyOutboundUtunFrame(frame) { + case .injectToGvproxy(let packet, _): + guard let ethernet = packetBridge.ethernetFrameForGvproxy(packet) else { continue } + ethernet.withUnsafeBytes { raw in + _ = send(gvproxyFD, raw.baseAddress, raw.count, 0) + } + case .ignore: + continue + } + } + } + + private func drainGvproxy() { + var buffer = [UInt8](repeating: 0, count: 65_536) + while true { + let received = recv(gvproxyFD, &buffer, buffer.count, MSG_DONTWAIT) + guard received > 0 else { break } + let frame = Data(buffer.prefix(received)) + guard let packet = packetBridge.ipv4PacketFromGvproxyFrame(frame), + let utunFrame = packetBridge.wrapInboundPacketForUtun(packet) else { + continue + } + utunFrame.withUnsafeBytes { raw in + _ = write(utunFD, raw.baseAddress, raw.count) + } + } + } + + private static func openUnixDatagram(localPath: String, remotePath: String) throws -> Int32 { + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { + throw DirectIPBridgeError.socket("cannot create direct-ip datagram socket: errno \(errno)") + } + do { + try bindUnixDatagram(descriptor, path: localPath) + try connectUnixDatagram(descriptor, path: remotePath) + var bufferSize = 1 << 20 + setsockopt(descriptor, SOL_SOCKET, SO_SNDBUF, &bufferSize, socklen_t(MemoryLayout.size)) + setsockopt(descriptor, SOL_SOCKET, SO_RCVBUF, &bufferSize, socklen_t(MemoryLayout.size)) + return descriptor + } catch { + close(descriptor) + throw error + } + } + + private static func bindUnixDatagram(_ descriptor: Int32, path: String) throws { + unlink(path) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + copyPath(path, into: &address) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + bind(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + throw DirectIPBridgeError.socket("cannot bind direct-ip socket \(path): errno \(errno)") + } + } + + private static func connectUnixDatagram(_ descriptor: Int32, path: String) throws { + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + copyPath(path, into: &address) + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + connect(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + throw DirectIPBridgeError.socket("cannot connect direct-ip socket \(path): errno \(errno)") + } + } + + private static func copyPath(_ path: String, into address: inout sockaddr_un) { + withUnsafeMutableBytes(of: &address.sun_path) { destination in + let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) + destination.copyBytes(from: bytes) + } + } + + private static func openUtun() throws -> (fileDescriptor: Int32, interfaceName: String) { + let descriptor = socket(PF_SYSTEM, SOCK_DGRAM, SYSPROTO_CONTROL) + guard descriptor >= 0 else { + throw DirectIPBridgeError.socket("cannot create utun control socket: errno \(errno)") + } + var info = ctl_info() + UTUN_CONTROL_NAME.withCString { name in + withUnsafeMutableBytes(of: &info.ctl_name) { destination in + destination.copyBytes(from: UnsafeRawBufferPointer(start: name, count: min(strlen(name), destination.count - 1))) + } + } + guard ioctl(descriptor, ctlIOCGetInfo, &info) == 0 else { + close(descriptor) + throw DirectIPBridgeError.socket("cannot resolve utun control id: errno \(errno)") + } + var address = sockaddr_ctl() + address.sc_len = UInt8(MemoryLayout.size) + address.sc_family = sa_family_t(AF_SYSTEM) + address.ss_sysaddr = UInt16(AF_SYS_CONTROL) + address.sc_id = info.ctl_id + address.sc_unit = 0 + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + connect(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + guard result == 0 else { + close(descriptor) + throw DirectIPBridgeError.socket("cannot connect utun control socket: errno \(errno)") + } + var nonblocking: Int32 = 1 + _ = ioctl(descriptor, fioNonBlocking, &nonblocking) + return (descriptor, try interfaceName(for: descriptor)) + } + + private static func interfaceName(for descriptor: Int32) throws -> String { + var buffer = [CChar](repeating: 0, count: Int(IFNAMSIZ)) + var length = socklen_t(buffer.count) + let result = getsockopt(descriptor, SYSPROTO_CONTROL, utunOptInterfaceName, &buffer, &length) + guard result == 0 else { + throw DirectIPBridgeError.socket("cannot read utun interface name: errno \(errno)") + } + let end = buffer.firstIndex(of: 0) ?? buffer.endIndex + return String(decoding: buffer[.. 0, "no open node") + appendToken(Self.endNode) + openNodes -= 1 + } + + public func property(_ name: String, bytes: [UInt8]) { + precondition(openNodes > 0, "property outside node") + appendToken(Self.prop) + appendToken(UInt32(bytes.count)) + appendToken(stringOffset(for: name)) + structure.append(contentsOf: bytes) + alignStructure() + } + + public func property(_ name: String, cells: [UInt32]) { + var bytes = [UInt8]() + bytes.reserveCapacity(cells.count * 4) + for cell in cells { + withUnsafeBytes(of: cell.bigEndian) { bytes.append(contentsOf: $0) } + } + property(name, bytes: bytes) + } + + public func property(_ name: String, cells64 values: [UInt64]) { + var cells = [UInt32]() + cells.reserveCapacity(values.count * 2) + for value in values { + cells.append(UInt32(value >> 32)) + cells.append(UInt32(value & 0xFFFF_FFFF)) + } + property(name, cells: cells) + } + + public func property(_ name: String, string value: String) { + property(name, bytes: [UInt8](value.utf8) + [0]) + } + + public func property(_ name: String, strings values: [String]) { + var bytes = [UInt8]() + for value in values { + bytes.append(contentsOf: [UInt8](value.utf8)) + bytes.append(0) + } + property(name, bytes: bytes) + } + + public func emptyProperty(_ name: String) { + property(name, bytes: []) + } + + public func finish(bootCPU: UInt32 = 0) -> [UInt8] { + precondition(openNodes == 0, "unbalanced nodes") + precondition(!finished, "already finished") + finished = true + appendToken(Self.end) + + let headerSize = 40 + let reserveMapSize = 16 + let structureOffset = headerSize + reserveMapSize + let stringsOffset = structureOffset + structure.count + let totalSize = stringsOffset + strings.count + + var blob = [UInt8]() + blob.reserveCapacity(totalSize) + appendBigEndian(Self.magic, to: &blob) + appendBigEndian(UInt32(totalSize), to: &blob) + appendBigEndian(UInt32(structureOffset), to: &blob) + appendBigEndian(UInt32(stringsOffset), to: &blob) + appendBigEndian(UInt32(headerSize), to: &blob) + appendBigEndian(UInt32(17), to: &blob) + appendBigEndian(UInt32(16), to: &blob) + appendBigEndian(bootCPU, to: &blob) + appendBigEndian(UInt32(strings.count), to: &blob) + appendBigEndian(UInt32(structure.count), to: &blob) + blob.append(contentsOf: [UInt8](repeating: 0, count: reserveMapSize)) + blob.append(contentsOf: structure) + blob.append(contentsOf: strings) + return blob + } + + private func appendToken(_ token: UInt32) { + appendBigEndian(token, to: &structure) + } + + private func appendBigEndian(_ value: UInt32, to buffer: inout [UInt8]) { + withUnsafeBytes(of: value.bigEndian) { buffer.append(contentsOf: $0) } + } + + private func appendNullTerminated(_ text: String) { + structure.append(contentsOf: [UInt8](text.utf8)) + structure.append(0) + } + + private func alignStructure() { + while structure.count % 4 != 0 { structure.append(0) } + } + + private func stringOffset(for name: String) -> UInt32 { + if let existing = stringOffsets[name] { return existing } + let offset = UInt32(strings.count) + strings.append(contentsOf: [UInt8](name.utf8)) + strings.append(0) + stringOffsets[name] = offset + return offset + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift new file mode 100644 index 0000000..677d10d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift @@ -0,0 +1,129 @@ +import Foundation + +public enum DaxWindowError: Error, Equatable { + case invalidWindow + case unaligned + case outOfBounds + case overlap + case missingMapping + case mappingFailed(String) + case unmappingFailed(String) +} + +public struct DaxMapping: Equatable, Sendable { + public var fileHandle: UInt64 + public var fileOffset: UInt64 + public var memoryOffset: UInt64 + public var length: UInt64 + public var flags: UInt64 + + public init(fileHandle: UInt64, fileOffset: UInt64, memoryOffset: UInt64, length: UInt64, flags: UInt64 = 0) { + self.fileHandle = fileHandle + self.fileOffset = fileOffset + self.memoryOffset = memoryOffset + self.length = length + self.flags = flags + } +} + +public protocol DaxMappingBackend: AnyObject, Sendable { + func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws + func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws +} + +public final class DaxWindow: @unchecked Sendable { + public static let defaultSize: UInt64 = 4 * 1024 * 1024 * 1024 + public static let pageSize: UInt64 = 16384 + + public let guestBase: UInt64 + public let length: UInt64 + private let backend: DaxMappingBackend? + private var mappings: [DaxMapping] = [] + private let lock = NSLock() + + public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize, backend: DaxMappingBackend? = nil) throws { + guard length > 0, guestBase.isMultiple(of: Self.pageSize), length.isMultiple(of: Self.pageSize) else { + throw DaxWindowError.invalidWindow + } + self.guestBase = guestBase + self.length = length + self.backend = backend + } + + public var activeMappings: [DaxMapping] { + lock.lock() + defer { lock.unlock() } + return mappings.sorted { $0.memoryOffset < $1.memoryOffset } + } + + public func setup(_ request: FuseSetupMappingIn, fileDescriptor: Int32? = nil) throws -> DaxMapping { + let mapping = DaxMapping( + fileHandle: request.fileHandle, + fileOffset: request.fileOffset, + memoryOffset: request.memoryOffset, + length: request.length, + flags: request.flags + ) + try validate(mapping) + if backend != nil, fileDescriptor == nil { + throw DaxWindowError.mappingFailed("missing file descriptor") + } + lock.lock() + defer { lock.unlock() } + guard !mappings.contains(where: { rangesOverlap($0.memoryOffset, $0.length, mapping.memoryOffset, mapping.length) }) else { + throw DaxWindowError.overlap + } + if let backend, let fileDescriptor { + try backend.map(mapping, fileDescriptor: fileDescriptor, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) + } + mappings.append(mapping) + return mapping + } + + public func remove(_ request: FuseRemoveMappingIn) throws { + lock.lock() + defer { lock.unlock() } + for entry in request.mappings { + guard entry.memoryOffset.isMultiple(of: Self.pageSize), + entry.length > 0, + entry.length.isMultiple(of: Self.pageSize) else { + throw DaxWindowError.unaligned + } + let overlapping = mappings.indices.filter { + rangesOverlap(mappings[$0].memoryOffset, mappings[$0].length, entry.memoryOffset, entry.length) + } + for index in overlapping.reversed() { + let mapping = mappings[index] + if let backend { + try backend.unmap(mapping, guestAddress: try guestAddress(forMemoryOffset: mapping.memoryOffset)) + } + mappings.remove(at: index) + } + } + } + + public func guestAddress(forMemoryOffset offset: UInt64) throws -> UInt64 { + guard offset < length else { throw DaxWindowError.outOfBounds } + return guestBase + offset + } + + private func validate(_ mapping: DaxMapping) throws { + guard mapping.fileOffset.isMultiple(of: Self.pageSize), + mapping.memoryOffset.isMultiple(of: Self.pageSize), + mapping.length > 0, + mapping.length.isMultiple(of: Self.pageSize) else { + throw DaxWindowError.unaligned + } + guard mapping.memoryOffset < length, + mapping.length <= length, + mapping.memoryOffset <= length - mapping.length else { + throw DaxWindowError.outOfBounds + } + } + + private func rangesOverlap(_ aStart: UInt64, _ aLength: UInt64, _ bStart: UInt64, _ bLength: UInt64) -> Bool { + let aEnd = aStart + aLength + let bEnd = bStart + bLength + return aStart < bEnd && bStart < aEnd + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift new file mode 100644 index 0000000..d186fa4 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FileBackedDaxMappingBackend.swift @@ -0,0 +1,83 @@ +import Darwin +import Foundation +import Hypervisor + +public final class FileBackedDaxMappingBackend: DaxMappingBackend, @unchecked Sendable { + private struct Region { + var hostAddress: UnsafeMutableRawPointer + var length: Int + } + + private let lock = NSLock() + private var regions: [Key: Region] = [:] + + public init() {} + + public func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws { + let length = try intLength(mapping.length) + let protections = protections(for: mapping.flags) + let hostAddress = mmap(nil, length, protections, MAP_SHARED, fileDescriptor, off_t(mapping.fileOffset)) + guard let hostAddress, hostAddress != MAP_FAILED else { + throw DaxWindowError.mappingFailed("mmap failed: errno \(errno)") + } + + let hvFlags = hv_memory_flags_t(hvFlags(for: mapping.flags)) + let result = hv_vm_map(hostAddress, guestAddress, length, hvFlags) + guard result == HV_SUCCESS else { + munmap(hostAddress, length) + throw DaxWindowError.mappingFailed("hv_vm_map(host=\(hostAddress) gpa=0x\(String(guestAddress, radix: 16)) len=0x\(String(length, radix: 16)) flags=0x\(String(hvFlags, radix: 16))) -> 0x\(String(UInt32(bitPattern: result), radix: 16))") + } + + lock.withLock { + regions[Key(memoryOffset: mapping.memoryOffset, length: mapping.length)] = Region(hostAddress: hostAddress, length: length) + } + } + + public func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws { + let key = Key(memoryOffset: mapping.memoryOffset, length: mapping.length) + guard let region = lock.withLock({ regions.removeValue(forKey: key) }) else { + throw DaxWindowError.unmappingFailed("mapping not found") + } + let unmapResult = hv_vm_unmap(guestAddress, region.length) + let munmapResult = munmap(region.hostAddress, region.length) + guard unmapResult == HV_SUCCESS, munmapResult == 0 else { + throw DaxWindowError.unmappingFailed("hv_vm_unmap \(unmapResult), munmap errno \(errno)") + } + } + + private func intLength(_ value: UInt64) throws -> Int { + guard value <= UInt64(Int.max) else { + throw DaxWindowError.mappingFailed("mapping length overflows Int") + } + return Int(value) + } + + private func protections(for flags: UInt64) -> Int32 { + // Map the host region read+write regardless of the guest's requested access. Apple's + // hv_vm_map rejects a host region that is not writable (HV_ERROR); the guest's stage-2 + // protection below still restricts the guest to what it asked for, so a read-only DAX + // mapping cannot be used by the guest to modify the host file. + return PROT_READ | PROT_WRITE + } + + private func hvFlags(for flags: UInt64) -> UInt32 { + var hvFlags = HV_MEMORY_READ + if flags & FuseSetupMappingFlag.write.rawValue != 0 { + hvFlags |= HV_MEMORY_WRITE + } + return UInt32(hvFlags) + } + + private struct Key: Hashable { + var memoryOffset: UInt64 + var length: UInt64 + } +} + +private extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift new file mode 100644 index 0000000..f873996 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift @@ -0,0 +1,433 @@ +import Foundation + +public enum FuseProtocolError: Error, Equatable { + case shortFrame + case unsupportedMinor(UInt32) +} + +public enum FuseOpcode: UInt32, Sendable { + case lookup = 1 + case forget = 2 + case getattr = 3 + case setattr = 4 + case readlink = 5 + case symlink = 6 + case mkdir = 9 + case unlink = 10 + case rmdir = 11 + case rename = 12 + case open = 14 + case read = 15 + case write = 16 + case statfs = 17 + case release = 18 + case fsync = 20 + case setxattr = 21 + case getxattr = 22 + case listxattr = 23 + case flush = 25 + case initOp = 26 + case opendir = 27 + case readdir = 28 + case releasedir = 29 + case fsyncdir = 30 + case create = 35 + case interrupt = 36 + case bmap = 37 + case destroy = 38 + case ioctl = 39 + case poll = 40 + case notifyReply = 41 + case batchForget = 42 + case fallocate = 43 + case readdirplus = 44 + case rename2 = 45 + case lseek = 46 + case copyFileRange = 47 + case setupmapping = 48 + case removemapping = 49 +} + +public struct FuseInHeader: Equatable, Sendable { + public static let byteCount = 40 + + public var length: UInt32 + public var opcode: UInt32 + public var unique: UInt64 + public var nodeID: UInt64 + public var uid: UInt32 + public var gid: UInt32 + public var pid: UInt32 + public var totalExtlen: UInt16 + public var padding: UInt16 + + public init( + length: UInt32, + opcode: UInt32, + unique: UInt64, + nodeID: UInt64, + uid: UInt32, + gid: UInt32, + pid: UInt32, + totalExtlen: UInt16 = 0, + padding: UInt16 = 0 + ) { + self.length = length + self.opcode = opcode + self.unique = unique + self.nodeID = nodeID + self.uid = uid + self.gid = gid + self.pid = pid + self.totalExtlen = totalExtlen + self.padding = padding + } +} + +public struct FuseOutHeader: Equatable, Sendable { + public static let byteCount = 16 + + public var length: UInt32 + public var error: Int32 + public var unique: UInt64 + + public init(length: UInt32, error: Int32, unique: UInt64) { + self.length = length + self.error = error + self.unique = unique + } +} + +public struct FuseInitIn: Equatable, Sendable { + public static let byteCount = 16 + + public var major: UInt32 + public var minor: UInt32 + public var maxReadahead: UInt32 + public var flags: UInt32 + + public init(major: UInt32, minor: UInt32, maxReadahead: UInt32, flags: UInt32) { + self.major = major + self.minor = minor + self.maxReadahead = maxReadahead + self.flags = flags + } +} + +public struct FuseInitOut: Equatable, Sendable { + public static let byteCount = 64 + + public var major: UInt32 + public var minor: UInt32 + public var maxReadahead: UInt32 + public var flags: UInt32 + public var maxBackground: UInt16 + public var congestionThreshold: UInt16 + public var maxWrite: UInt32 + public var timeGranularityNanoseconds: UInt32 + public var maxPages: UInt16 + public var mapAlignment: UInt16 + + public init( + major: UInt32 = FuseProtocol.majorVersion, + minor: UInt32 = FuseProtocol.minorVersion, + maxReadahead: UInt32 = 1 << 20, + flags: UInt32 = FuseInitFlag.asyncRead.rawValue | FuseInitFlag.bigWrites.rawValue | FuseInitFlag.autoInvalidateData.rawValue, + maxBackground: UInt16 = 64, + congestionThreshold: UInt16 = 48, + maxWrite: UInt32 = 1 << 20, + timeGranularityNanoseconds: UInt32 = 1, + maxPages: UInt16 = 256, + mapAlignment: UInt16 = 0 + ) { + self.major = major + self.minor = minor + self.maxReadahead = maxReadahead + self.flags = flags + self.maxBackground = maxBackground + self.congestionThreshold = congestionThreshold + self.maxWrite = maxWrite + self.timeGranularityNanoseconds = timeGranularityNanoseconds + self.maxPages = maxPages + self.mapAlignment = mapAlignment + } +} + +public struct FuseInitFlag: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let asyncRead = FuseInitFlag(rawValue: 1 << 0) + public static let bigWrites = FuseInitFlag(rawValue: 1 << 5) + public static let autoInvalidateData = FuseInitFlag(rawValue: 1 << 12) + public static let doReaddirplus = FuseInitFlag(rawValue: 1 << 13) + public static let readdirplusAuto = FuseInitFlag(rawValue: 1 << 14) + public static let maxPages = FuseInitFlag(rawValue: 1 << 22) + public static let mapAlignment = FuseInitFlag(rawValue: 1 << 26) +} + +public struct FuseSetattrValid: OptionSet, Sendable { + public let rawValue: UInt32 + + public init(rawValue: UInt32) { + self.rawValue = rawValue + } + + public static let mode = FuseSetattrValid(rawValue: 1 << 0) + public static let uid = FuseSetattrValid(rawValue: 1 << 1) + public static let gid = FuseSetattrValid(rawValue: 1 << 2) + public static let size = FuseSetattrValid(rawValue: 1 << 3) + public static let atime = FuseSetattrValid(rawValue: 1 << 4) + public static let mtime = FuseSetattrValid(rawValue: 1 << 5) + public static let fileHandle = FuseSetattrValid(rawValue: 1 << 6) +} + +public struct FuseSetupMappingIn: Equatable, Sendable { + public static let byteCount = 40 // fuse_setupmapping_in: fh, foffset, len, flags, moffset (5x u64) + + public var fileHandle: UInt64 + public var fileOffset: UInt64 + public var length: UInt64 + public var flags: UInt64 + public var memoryOffset: UInt64 + + public init(fileHandle: UInt64, fileOffset: UInt64, length: UInt64, flags: UInt64, memoryOffset: UInt64) { + self.fileHandle = fileHandle + self.fileOffset = fileOffset + self.length = length + self.flags = flags + self.memoryOffset = memoryOffset + } +} + +public struct FuseSetupMappingFlag: OptionSet, Sendable { + public let rawValue: UInt64 + + public init(rawValue: UInt64) { + self.rawValue = rawValue + } + + public static let write = FuseSetupMappingFlag(rawValue: 1 << 0) + public static let read = FuseSetupMappingFlag(rawValue: 1 << 1) +} + +public struct FuseRemoveMappingIn: Equatable, Sendable { + public static let headerByteCount = 8 + public static let oneByteCount = 16 + + public var mappings: [FuseRemoveMappingOne] + + public init(mappings: [FuseRemoveMappingOne]) { + self.mappings = mappings + } +} + +public struct FuseRemoveMappingOne: Equatable, Sendable { + public var memoryOffset: UInt64 + public var length: UInt64 + + public init(memoryOffset: UInt64, length: UInt64) { + self.memoryOffset = memoryOffset + self.length = length + } +} + +public enum FuseProtocol { + public static let majorVersion: UInt32 = 7 + public static let minorVersion: UInt32 = 38 + public static let minimumMinorVersion: UInt32 = 27 + public static let eproto: Int32 = 71 + + public static func decodeInHeader(_ data: [UInt8]) throws -> FuseInHeader { + guard data.count >= FuseInHeader.byteCount else { throw FuseProtocolError.shortFrame } + return FuseInHeader( + length: data.leUInt32(at: 0), + opcode: data.leUInt32(at: 4), + unique: data.leUInt64(at: 8), + nodeID: data.leUInt64(at: 16), + uid: data.leUInt32(at: 24), + gid: data.leUInt32(at: 28), + pid: data.leUInt32(at: 32), + totalExtlen: data.leUInt16(at: 36), + padding: data.leUInt16(at: 38) + ) + } + + public static func encodeInHeader(_ header: FuseInHeader) -> [UInt8] { + var data = [UInt8]() + data.appendLE(header.length) + data.appendLE(header.opcode) + data.appendLE(header.unique) + data.appendLE(header.nodeID) + data.appendLE(header.uid) + data.appendLE(header.gid) + data.appendLE(header.pid) + data.appendLE(header.totalExtlen) + data.appendLE(header.padding) + return data + } + + public static func encodeOutHeader(_ header: FuseOutHeader) -> [UInt8] { + var data = [UInt8]() + data.appendLE(header.length) + data.appendLE(UInt32(bitPattern: header.error)) + data.appendLE(header.unique) + return data + } + + public static func decodeOutHeader(_ data: [UInt8]) throws -> FuseOutHeader { + guard data.count >= FuseOutHeader.byteCount else { throw FuseProtocolError.shortFrame } + return FuseOutHeader( + length: data.leUInt32(at: 0), + error: Int32(bitPattern: data.leUInt32(at: 4)), + unique: data.leUInt64(at: 8) + ) + } + + public static func decodeInitIn(_ data: [UInt8]) throws -> FuseInitIn { + guard data.count >= FuseInitIn.byteCount else { throw FuseProtocolError.shortFrame } + return FuseInitIn( + major: data.leUInt32(at: 0), + minor: data.leUInt32(at: 4), + maxReadahead: data.leUInt32(at: 8), + flags: data.leUInt32(at: 12) + ) + } + + public static func encodeInitOut(_ value: FuseInitOut) -> [UInt8] { + var data = [UInt8]() + data.appendLE(value.major) + data.appendLE(value.minor) + data.appendLE(value.maxReadahead) + data.appendLE(value.flags) + data.appendLE(value.maxBackground) + data.appendLE(value.congestionThreshold) + data.appendLE(value.maxWrite) + data.appendLE(value.timeGranularityNanoseconds) + data.appendLE(value.maxPages) + data.appendLE(value.mapAlignment) + data.appendLE(UInt32(0)) + data.appendLE(UInt32(0)) + data.appendLE(UInt16(0)) + for _ in 0..<11 { + data.appendLE(UInt16(0)) + } + return data + } + + public static func negotiateInit(header: FuseInHeader, request: FuseInitIn, daxMapAlignmentLog2: UInt16? = nil) -> [UInt8] { + guard request.minor >= minimumMinorVersion else { + return encodeOutHeader(FuseOutHeader(length: UInt32(FuseOutHeader.byteCount), error: -eproto, unique: header.unique)) + } + // FUSE_AUTO_INVAL_DATA is safe to advertise ONLY because getattr now reports real mtime + // nanoseconds: under this flag the kernel drops the page cache whenever a cached read sees a + // changed mtime, and previously every attr carried mtime_nsec=0, so an unchanged host file + // still looked modified on each revalidation and lost its cache — collapsing reads to a FUSE + // round-trip per 4 KiB. With correct nsecs it invalidates only on a genuine host change. + // DO_READDIRPLUS (without READDIRPLUS_AUTO) forces the guest to use FUSE_READDIRPLUS, which + // the server handles, for every directory read. Plain FUSE_READDIR is NOT handled, so + // advertising AUTO — which lets the kernel fall back to plain readdir — would make some + // `ls` calls list an empty directory. Force readdirplus until plain readdir is implemented. + var flags = FuseInitFlag.asyncRead.rawValue | FuseInitFlag.bigWrites.rawValue + | FuseInitFlag.autoInvalidateData.rawValue | FuseInitFlag.maxPages.rawValue + | FuseInitFlag.doReaddirplus.rawValue + if daxMapAlignmentLog2 != nil { + flags |= FuseInitFlag.mapAlignment.rawValue + } + let response = FuseInitOut( + major: majorVersion, + minor: min(request.minor, minorVersion), + maxReadahead: request.maxReadahead, + flags: flags, + mapAlignment: daxMapAlignmentLog2 ?? 0 + ) + return encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount + FuseInitOut.byteCount), + error: 0, + unique: header.unique + )) + encodeInitOut(response) + } + + public static func decodeSetupMappingIn(_ data: [UInt8]) throws -> FuseSetupMappingIn { + guard data.count >= FuseSetupMappingIn.byteCount else { throw FuseProtocolError.shortFrame } + return FuseSetupMappingIn( + fileHandle: data.leUInt64(at: 0), + fileOffset: data.leUInt64(at: 8), + length: data.leUInt64(at: 16), + flags: data.leUInt64(at: 24), + memoryOffset: data.leUInt64(at: 32) + ) + } + + public static func encodeSetupMappingIn(_ value: FuseSetupMappingIn) -> [UInt8] { + var data = [UInt8]() + data.appendLE(value.fileHandle) + data.appendLE(value.fileOffset) + data.appendLE(value.length) + data.appendLE(value.flags) + data.appendLE(value.memoryOffset) + return data + } + + public static func decodeRemoveMappingIn(_ data: [UInt8]) throws -> FuseRemoveMappingIn { + guard data.count >= FuseRemoveMappingIn.headerByteCount else { throw FuseProtocolError.shortFrame } + let count = Int(data.leUInt32(at: 0)) + let expected = FuseRemoveMappingIn.headerByteCount + count * FuseRemoveMappingIn.oneByteCount + guard data.count >= expected else { throw FuseProtocolError.shortFrame } + var mappings = [FuseRemoveMappingOne]() + mappings.reserveCapacity(count) + var offset = FuseRemoveMappingIn.headerByteCount + for _ in 0.. [UInt8] { + var data = [UInt8]() + data.appendLE(UInt32(value.mappings.count)) + data.appendLE(UInt32(0)) + for mapping in value.mappings { + data.appendLE(mapping.memoryOffset) + data.appendLE(mapping.length) + } + return data + } +} + +private extension Array where Element == UInt8 { + mutating func appendLE(_ value: UInt16) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + + mutating func appendLE(_ value: UInt32) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + + mutating func appendLE(_ value: UInt64) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + + func leUInt32(at offset: Int) -> UInt32 { + let raw = UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + return raw + } + + func leUInt16(at offset: Int) -> UInt16 { + UInt16(self[offset]) | UInt16(self[offset + 1]) << 8 + } + + func leUInt64(at offset: Int) -> UInt64 { + UInt64(leUInt32(at: offset)) | UInt64(leUInt32(at: offset + 4)) << 32 + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift new file mode 100644 index 0000000..74010c1 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseServer.swift @@ -0,0 +1,519 @@ +import Darwin +import Foundation + +public final class FuseServer: @unchecked Sendable { + private let hostFS: HostFS + private let daxWindow: DaxWindow? + private let lock = NSLock() + private var nextHandle: UInt64 = 1 + private var fileHandles: [UInt64: Int32] = [:] + + public init(hostFS: HostFS, daxWindow: DaxWindow? = nil) { + self.hostFS = hostFS + self.daxWindow = daxWindow + } + + public func handle(request: [UInt8]) -> [UInt8] { + guard let header = try? FuseProtocol.decodeInHeader(request), + Int(header.length) <= request.count, + header.length >= UInt32(FuseInHeader.byteCount) else { + return errorResponse(unique: 0, errno: EINVAL) + } + + let payload = Array(request[Int(FuseInHeader.byteCount).. [UInt8] { + let name = try readCString(payload) + let entry = try hostFS.lookup(parent: header.nodeID, name: name) + return successResponse(unique: header.unique, payload: encodeEntryOut(entry.attributes)) + } + + private func handleGetattr(header: FuseInHeader) throws -> [UInt8] { + let attrs = try hostFS.getattr(nodeID: header.nodeID) + return successResponse(unique: header.unique, payload: encodeAttrOut(attrs)) + } + + private func handleSetattr(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 88 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let valid = FuseSetattrValid(rawValue: payload.leUInt32(at: 0)) + let attrs = try hostFS.getattr(nodeID: header.nodeID) + if valid.contains(.mode) { + let requestedMode = payload.leUInt32(at: 68) & 0o7777 + let currentMode = attrs.mode & 0o7777 + guard requestedMode == currentMode else { + return errorResponse(unique: header.unique, errno: EOPNOTSUPP) + } + } + if valid.contains(.size) { + let size = payload.leUInt64(at: 16) + if valid.contains(.fileHandle), let fd = load(handle: payload.leUInt64(at: 8)) { + try hostFS.truncate(handle: fd, size: size) + } else { + try hostFS.truncate(nodeID: header.nodeID, size: size) + } + return try successResponse(unique: header.unique, payload: encodeAttrOut(hostFS.getattr(nodeID: header.nodeID))) + } + return successResponse(unique: header.unique, payload: encodeAttrOut(attrs)) + } + + private func handleOpen(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 8 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let flags = Int32(bitPattern: payload.leUInt32(at: 0)) + let fd = flags & O_ACCMODE == O_RDONLY + ? try hostFS.openRead(nodeID: header.nodeID) + : try hostFS.openReadWrite(nodeID: header.nodeID) + let handle = store(fd: fd) + return successResponse(unique: header.unique, payload: encodeOpenOut(handle: handle, openFlags: 1 << 1)) + } + + private func handleOpenDir(header: FuseInHeader) throws -> [UInt8] { + _ = try hostFS.getattr(nodeID: header.nodeID) + return successResponse(unique: header.unique, payload: encodeOpenOut(handle: header.nodeID, openFlags: 1 << 3)) + } + + private func handleRead(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 40 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let handle = payload.leUInt64(at: 0) + let offset = payload.leUInt64(at: 8) + let size = min(Int(payload.leUInt32(at: 16)), HostFS.maxReadCount) + guard let fd = load(handle: handle) else { + return errorResponse(unique: header.unique, errno: EBADF) + } + return try successResponse(unique: header.unique, payload: hostFS.read(handle: fd, offset: offset, count: size)) + } + + /// Zero-copy READ: `preadv` the payload straight into the guest's device-writable descriptor + /// segments (scatter-gather, so any header+data split works) and write the fuse_out_header in + /// place, returning total bytes produced. Avoids the intermediate read buffer, the response + /// array, and the copy back into guest memory that the array path incurs. Returns 0 to signal + /// the caller to fall back (e.g. the first segment is too small to hold the out header). + public func writeReadResponse(header: FuseInHeader, payload: [UInt8], writable: [VirtqueueSegment]) -> Int { + guard let first = writable.first, first.length >= FuseOutHeader.byteCount else { return 0 } + let totalCapacity = writable.reduce(0) { $0 + $1.length } + func finish(errno: Int32, payloadBytes: Int) -> Int { + let total = FuseOutHeader.byteCount + payloadBytes + first.pointer.storeBytes(of: UInt32(total).littleEndian, toByteOffset: 0, as: UInt32.self) + first.pointer.storeBytes(of: Int32(-errno).littleEndian, toByteOffset: 4, as: Int32.self) + first.pointer.storeBytes(of: header.unique.littleEndian, toByteOffset: 8, as: UInt64.self) + return total + } + guard payload.count >= 40 else { return finish(errno: EINVAL, payloadBytes: 0) } + let size = min(Int(payload.leUInt32(at: 16)), HostFS.maxReadCount) + guard let signedOffset = off_t(exactly: payload.leUInt64(at: 8)) else { + return finish(errno: EINVAL, payloadBytes: 0) + } + guard let fd = load(handle: payload.leUInt64(at: 0)) else { + return finish(errno: EBADF, payloadBytes: 0) + } + let dataCapacity = min(size, totalCapacity - FuseOutHeader.byteCount) + guard dataCapacity > 0 else { return finish(errno: 0, payloadBytes: 0) } + + // Build iovecs over the writable bytes AFTER the 16-byte out header. + var iovecs = [iovec]() + var remaining = dataCapacity + var skip = FuseOutHeader.byteCount + for segment in writable where remaining > 0 { + var base = segment.pointer + var length = segment.length + if skip > 0 { + let drop = min(skip, length) + base = base.advanced(by: drop) + length -= drop + skip -= drop + } + guard length > 0 else { continue } + let take = min(length, remaining) + iovecs.append(iovec(iov_base: base, iov_len: take)) + remaining -= take + } + let readCount = preadv(fd, iovecs, Int32(iovecs.count), signedOffset) + guard readCount >= 0 else { return finish(errno: errno, payloadBytes: 0) } + return finish(errno: 0, payloadBytes: Int(readCount)) + } + + private func handleWrite(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 40 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let handle = payload.leUInt64(at: 0) + let offset = payload.leUInt64(at: 8) + let size = Int(payload.leUInt32(at: 16)) + guard payload.count >= 40 + size else { return errorResponse(unique: header.unique, errno: EINVAL) } + guard let fd = load(handle: handle) else { + return errorResponse(unique: header.unique, errno: EBADF) + } + let data = Array(payload[40..<(40 + size)]) + let written = try hostFS.write(handle: fd, offset: offset, data: data) + var response = [UInt8]() + response.appendLE(UInt32(written)) + response.appendLE(UInt32(0)) + return successResponse(unique: header.unique, payload: response) + } + + private func handleReadDirPlus(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 40 else { return errorResponse(unique: header.unique, errno: EINVAL) } + guard let offset = Int(exactly: payload.leUInt64(at: 8)) else { + return errorResponse(unique: header.unique, errno: EINVAL) + } + let maxSize = Int(payload.leUInt32(at: 16)) + let entries = try hostFS.readdirplus(nodeID: header.nodeID) + var data = [UInt8]() + for (index, entry) in entries.enumerated().dropFirst(offset) { + let encoded = encodeDirentPlus(entry, offset: UInt64(index + 1)) + guard data.count + encoded.count <= maxSize else { break } + data.append(contentsOf: encoded) + } + return successResponse(unique: header.unique, payload: data) + } + + private func handleStatFS(header: FuseInHeader) throws -> [UInt8] { + let stat = try hostFS.statfs() + var data = [UInt8]() + data.appendLE(stat.blocks) + data.appendLE(stat.blocksFree) + data.appendLE(stat.blocksAvailable) + data.appendLE(stat.files) + data.appendLE(stat.filesFree) + data.appendLE(UInt32(clamping: stat.blockSize)) + data.appendLE(stat.nameMax) + data.appendLE(UInt32(clamping: stat.blockSize)) + data.appendLE(UInt32(0)) + for _ in 0..<6 { data.appendLE(UInt32(0)) } + return successResponse(unique: header.unique, payload: data) + } + + private func handleFsync(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 16 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let handle = payload.leUInt64(at: 0) + guard let fd = load(handle: handle) else { + return errorResponse(unique: header.unique, errno: EBADF) + } + try hostFS.fsync(handle: fd) + return successResponse(unique: header.unique, payload: []) + } + + private func handleCreate(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 16 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 4)) + let name = try readCString(Array(payload.dropFirst(16))) + let entry = try hostFS.createFile(parent: header.nodeID, name: name, mode: mode) + let fd = try hostFS.openReadWrite(nodeID: entry.nodeID) + let handle = store(fd: fd) + return successResponse(unique: header.unique, payload: encodeEntryOut(entry.attributes) + encodeOpenOut(handle: handle, openFlags: 1 << 1)) + } + + private func handleMkdir(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 8 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let mode = UInt16(truncatingIfNeeded: payload.leUInt32(at: 0)) + let name = try readCString(Array(payload.dropFirst(8))) + let entry = try hostFS.mkdir(parent: header.nodeID, name: name, mode: mode) + return successResponse(unique: header.unique, payload: encodeEntryOut(entry.attributes)) + } + + private func handleUnlink(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + try hostFS.unlink(parent: header.nodeID, name: readCString(payload)) + return successResponse(unique: header.unique, payload: []) + } + + private func handleRmdir(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + try hostFS.rmdir(parent: header.nodeID, name: readCString(payload)) + return successResponse(unique: header.unique, payload: []) + } + + private func handleRename(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard payload.count >= 8 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let newParent = payload.leUInt64(at: 0) + let names = try readCStrings(Array(payload.dropFirst(8)), count: 2) + _ = try hostFS.rename(parent: header.nodeID, name: names[0], newParent: newParent, newName: names[1]) + return successResponse(unique: header.unique, payload: []) + } + + private func handleRelease(header: FuseInHeader, payload: [UInt8]) -> [UInt8] { + guard payload.count >= 8 else { return errorResponse(unique: header.unique, errno: EINVAL) } + let handle = payload.leUInt64(at: 0) + if let fd = remove(handle: handle) { + hostFS.close(handle: fd) + } + return successResponse(unique: header.unique, payload: []) + } + + private func handleSetupMapping(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard let daxWindow else { + return errorResponse(unique: header.unique, errno: ENOSYS) + } + let request = try FuseProtocol.decodeSetupMappingIn(payload) + // virtio-fs sends fh = -1 for inode-based DAX mappings; resolve the file from the node id. + // The backend mmaps read-write (Apple's hv_vm_map rejects a read-only host region), so the + // fd must be writable; a read-only file therefore falls back to plain FUSE reads via the + // thrown error. The backend keeps its own mmap, so a temporary open is closed after setup. + let fd: Int32 + var temporaryFD: Int32? + if request.fileHandle == UInt64.max { + fd = try hostFS.openReadWrite(nodeID: header.nodeID) + temporaryFD = fd + } else if let open = load(handle: request.fileHandle) { + fd = open + } else { + return errorResponse(unique: header.unique, errno: EBADF) + } + defer { if let temporaryFD { hostFS.close(handle: temporaryFD) } } + _ = try daxWindow.setup(request, fileDescriptor: fd) + return successResponse(unique: header.unique, payload: []) + } + + private func handleRemoveMapping(header: FuseInHeader, payload: [UInt8]) throws -> [UInt8] { + guard let daxWindow else { + return errorResponse(unique: header.unique, errno: ENOSYS) + } + let request = try FuseProtocol.decodeRemoveMappingIn(payload) + try daxWindow.remove(request) + return successResponse(unique: header.unique, payload: []) + } + + private func store(fd: Int32) -> UInt64 { + lock.withLock { + let handle = nextHandle + nextHandle += 1 + fileHandles[handle] = fd + return handle + } + } + + private func load(handle: UInt64) -> Int32? { + lock.withLock { fileHandles[handle] } + } + + private func remove(handle: UInt64) -> Int32? { + lock.withLock { fileHandles.removeValue(forKey: handle) } + } + + private func readCString(_ payload: [UInt8]) throws -> String { + guard let terminator = payload.firstIndex(of: 0), + let string = String(bytes: payload[.. [String] { + var strings = [String]() + var start = payload.startIndex + while strings.count < count { + guard let end = payload[start...].firstIndex(of: 0), + let string = String(bytes: payload[start.. [UInt8] { + FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount + payload.count), + error: 0, + unique: unique + )) + payload + } + + private func errorResponse(unique: UInt64, errno: Int32) -> [UInt8] { + FuseProtocol.encodeOutHeader(FuseOutHeader( + length: UInt32(FuseOutHeader.byteCount), + error: -errno, + unique: unique + )) + } + + // Cache-validity window handed to the guest for looked-up entries and attributes. A zero + // attr_valid forces the guest to revalidate with a GETATTR on essentially every access and + // prevents the page cache from being trusted, collapsing read throughput to a FUSE round-trip + // per 4 KiB. One second is the cache=auto default (virtiofsd): the guest trusts cached + // metadata and data for up to a second, revalidating on open via mtime. + static let cacheValiditySeconds: UInt64 = 1 + + private func encodeEntryOut(_ attrs: HostFSAttributes) -> [UInt8] { + var data = [UInt8]() + data.appendLE(attrs.nodeID) + data.appendLE(UInt64(1)) + data.appendLE(Self.cacheValiditySeconds) // entry_valid + data.appendLE(Self.cacheValiditySeconds) // attr_valid + data.appendLE(UInt32(0)) + data.appendLE(UInt32(0)) + data.append(contentsOf: encodeAttr(attrs)) + return data + } + + private func encodeAttrOut(_ attrs: HostFSAttributes) -> [UInt8] { + var data = [UInt8]() + data.appendLE(Self.cacheValiditySeconds) // attr_valid + data.appendLE(UInt32(0)) + data.appendLE(UInt32(0)) + data.append(contentsOf: encodeAttr(attrs)) + return data + } + + private func encodeOpenOut(handle: UInt64, openFlags: UInt32) -> [UInt8] { + var data = [UInt8]() + data.appendLE(handle) + data.appendLE(openFlags) + data.appendLE(UInt32(0)) + return data + } + + private func encodeDirentPlus(_ entry: HostFSEntry, offset: UInt64) -> [UInt8] { + let name = Array(entry.name.utf8) + var data = encodeEntryOut(entry.attributes) + data.appendLE(entry.nodeID) + data.appendLE(offset) + data.appendLE(UInt32(name.count)) + data.appendLE(direntType(for: entry.attributes)) + data.append(contentsOf: name) + while data.count % 8 != 0 { data.append(0) } + return data + } + + private func encodeAttr(_ attrs: HostFSAttributes) -> [UInt8] { + var data = [UInt8]() + data.appendLE(attrs.nodeID) + data.appendLE(attrs.size) + data.appendLE((attrs.size + 511) / 512) + data.appendLE(UInt64(bitPattern: attrs.atimeSeconds)) + data.appendLE(UInt64(bitPattern: attrs.mtimeSeconds)) + data.appendLE(UInt64(bitPattern: attrs.ctimeSeconds)) + data.appendLE(attrs.atimeNsec) + data.appendLE(attrs.mtimeNsec) + data.appendLE(attrs.ctimeNsec) + data.appendLE(attrs.mode) + data.appendLE(attrs.isDirectory ? UInt32(2) : UInt32(1)) + data.appendLE(attrs.uid) + data.appendLE(attrs.gid) + data.appendLE(UInt32(0)) + data.appendLE(UInt32(4096)) + data.appendLE(UInt32(0)) + return data + } + + private func direntType(for attrs: HostFSAttributes) -> UInt32 { + if attrs.isDirectory { return 4 } + if attrs.isSymlink { return 10 } + if attrs.isRegularFile { return 8 } + return 0 + } + + private func mapError(_ error: Error) -> Int32 { + switch error { + case HostFSError.invalidRoot, HostFSError.io: + return EIO + case HostFSError.invalidName: + return EINVAL + case HostFSError.notFound: + return ENOENT + case HostFSError.notDirectory: + return ENOTDIR + case HostFSError.notRegularFile: + return EISDIR + case HostFSError.readOnly: + return EROFS + case HostFSError.permissionDenied: + return EACCES + case FuseProtocolError.shortFrame: + return EINVAL + case FuseProtocolError.unsupportedMinor: + return EPROTO + case DaxWindowError.unaligned, DaxWindowError.outOfBounds, DaxWindowError.invalidWindow: + return EINVAL + case DaxWindowError.overlap: + return EBUSY + case DaxWindowError.missingMapping: + return ENOENT + case DaxWindowError.mappingFailed, DaxWindowError.unmappingFailed: + return EIO + default: + return EIO + } + } +} + +private extension Array where Element == UInt8 { + mutating func appendLE(_ value: UInt32) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + + mutating func appendLE(_ value: UInt64) { + Swift.withUnsafeBytes(of: value.littleEndian) { append(contentsOf: $0) } + } + + func leUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } + + func leUInt64(at offset: Int) -> UInt64 { + UInt64(leUInt32(at: offset)) | UInt64(leUInt32(at: offset + 4)) << 32 + } +} + +private extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift new file mode 100644 index 0000000..a56bbf6 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift @@ -0,0 +1,485 @@ +import Darwin +import Foundation + +public enum HostFSError: Error, Equatable { + case invalidRoot(String) + case invalidName(String) + case notFound(String) + case notDirectory(UInt64) + case notRegularFile(UInt64) + case readOnly + case permissionDenied(String) + case io(String) +} + +public struct HostFSAttributes: Equatable, Sendable { + public var nodeID: UInt64 + public var mode: UInt32 + public var size: UInt64 + public var uid: UInt32 + public var gid: UInt32 + public var atimeSeconds: Int64 + public var mtimeSeconds: Int64 + public var ctimeSeconds: Int64 + public var atimeNsec: UInt32 = 0 + public var mtimeNsec: UInt32 = 0 + public var ctimeNsec: UInt32 = 0 + + public var isDirectory: Bool { (mode & UInt32(S_IFMT)) == UInt32(S_IFDIR) } + public var isRegularFile: Bool { (mode & UInt32(S_IFMT)) == UInt32(S_IFREG) } + public var isSymlink: Bool { (mode & UInt32(S_IFMT)) == UInt32(S_IFLNK) } +} + +public struct HostFSEntry: Equatable, Sendable { + public var name: String + public var nodeID: UInt64 + public var attributes: HostFSAttributes +} + +public struct HostFSStat: Equatable, Sendable { + public var blockSize: UInt64 + public var blocks: UInt64 + public var blocksFree: UInt64 + public var blocksAvailable: UInt64 + public var files: UInt64 + public var filesFree: UInt64 + public var nameMax: UInt32 +} + +public final class HostFS: @unchecked Sendable { + public static let rootNodeID: UInt64 = 1 + public static let maxReadCount: Int = 1 << 20 + + private struct Node: Sendable { + var id: UInt64 + var relativePath: String + var attributes: HostFSAttributes + var fileKey: FileKey + } + + private let rootPath: String + private let rootFD: Int32 + private let guestUID: UInt32 + private let guestGID: UInt32 + private let readOnly: Bool + /// Entry names hidden from the guest at any depth. A lookup of a hidden name fails as if the + /// path does not exist, hidden entries are omitted from directory listings, and entry-creating + /// or entry-removing operations reject hidden names before touching the host. + private let hiddenNames: Set + private var nextNodeID: UInt64 = 2 + private var nodes: [UInt64: Node] = [:] + private var idsByFileKey: [FileKey: UInt64] = [:] + private var idsByRelativePath: [String: Set] = [:] + private let lock = NSLock() + + public init(rootPath: String, guestUID: UInt32 = 1000, guestGID: UInt32 = 1000, readOnly: Bool = false, hiddenNames: Set = []) throws { + var resolved = [CChar](repeating: 0, count: Int(PATH_MAX)) + guard realpath(rootPath, &resolved) != nil else { + throw HostFSError.invalidRoot(rootPath) + } + let rootBytes = resolved.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + let root = String(decoding: rootBytes, as: UTF8.self) + let fd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC) + guard fd >= 0 else { + throw HostFSError.invalidRoot(rootPath) + } + + self.rootPath = root + self.rootFD = fd + self.guestUID = guestUID + self.guestGID = guestGID + self.readOnly = readOnly + self.hiddenNames = hiddenNames + + var st = stat() + guard fstat(fd, &st) == 0 else { + Darwin.close(fd) + throw HostFSError.invalidRoot(rootPath) + } + let attrs = Self.attributes(from: st, nodeID: Self.rootNodeID, uid: guestUID, gid: guestGID) + self.nodes[Self.rootNodeID] = Node(id: Self.rootNodeID, relativePath: "", attributes: attrs, fileKey: FileKey(st)) + self.idsByFileKey[FileKey(st)] = Self.rootNodeID + self.idsByRelativePath["", default: []].insert(Self.rootNodeID) + } + + deinit { + Darwin.close(rootFD) + } + + public func getattr(nodeID: UInt64) throws -> HostFSAttributes { + let node = try node(for: nodeID) + var st = stat() + let result = node.relativePath.isEmpty + ? fstat(rootFD, &st) + : fstatat(rootFD, cPath(node.relativePath), &st, AT_SYMLINK_NOFOLLOW) + guard result == 0 else { + throw HostFSError.notFound(node.relativePath) + } + let attrs = Self.attributes(from: st, nodeID: nodeID, uid: guestUID, gid: guestGID) + if attrs != node.attributes { + lock.withLock { + nodes[nodeID]?.attributes = attrs + } + } + return attrs + } + + public func lookup(parent: UInt64, name: String) throws -> HostFSEntry { + try validateComponent(name) + try requireVisible(name) + let parentNode = try node(for: parent) + guard parentNode.attributes.isDirectory else { + throw HostFSError.notDirectory(parent) + } + let relative = join(parentNode.relativePath, name) + var st = stat() + guard fstatat(rootFD, cPath(relative), &st, AT_SYMLINK_NOFOLLOW) == 0 else { + throw HostFSError.notFound(name) + } + + let key = FileKey(st) + let id = lock.withLock { () -> UInt64 in + if let existing = idsByFileKey[key] { + return existing + } + let id = nextNodeID + nextNodeID += 1 + idsByFileKey[key] = id + return id + } + let attrs = Self.attributes(from: st, nodeID: id, uid: guestUID, gid: guestGID) + lock.withLock { + if let previous = nodes[id], previous.relativePath != relative { + idsByRelativePath[previous.relativePath]?.remove(id) + if idsByRelativePath[previous.relativePath]?.isEmpty == true { + idsByRelativePath.removeValue(forKey: previous.relativePath) + } + } + nodes[id] = Node(id: id, relativePath: relative, attributes: attrs, fileKey: key) + idsByRelativePath[relative, default: []].insert(id) + } + return HostFSEntry(name: name, nodeID: id, attributes: attrs) + } + + public func openRead(nodeID: UInt64) throws -> Int32 { + try openFile(nodeID: nodeID, flags: O_RDONLY) + } + + public func openReadWrite(nodeID: UInt64) throws -> Int32 { + guard !readOnly else { throw HostFSError.readOnly } + return try openFile(nodeID: nodeID, flags: O_RDWR) + } + + private func openFile(nodeID: UInt64, flags: Int32) throws -> Int32 { + let node = try node(for: nodeID) + guard node.attributes.isRegularFile else { + throw HostFSError.notRegularFile(nodeID) + } + let fd = openat(rootFD, cPath(node.relativePath), flags | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { + if errno == ELOOP { throw HostFSError.permissionDenied(node.relativePath) } + throw HostFSError.io("openat \(node.relativePath): errno \(errno)") + } + return fd + } + + public func read(handle fd: Int32, offset: UInt64, count: Int) throws -> [UInt8] { + guard count >= 0 else { throw HostFSError.invalidName("read count") } + guard let signedOffset = off_t(exactly: offset) else { + throw HostFSError.invalidName("read offset") + } + let clampedCount = min(count, Self.maxReadCount) + var buffer = [UInt8](repeating: 0, count: clampedCount) + let readCount = pread(fd, &buffer, clampedCount, signedOffset) + guard readCount >= 0 else { + throw HostFSError.io("pread: errno \(errno)") + } + if readCount < clampedCount { + buffer.removeSubrange(readCount.. Int { + guard !readOnly else { throw HostFSError.readOnly } + guard let signedOffset = off_t(exactly: offset) else { + throw HostFSError.invalidName("write offset") + } + let written = data.withUnsafeBytes { raw in + pwrite(fd, raw.baseAddress, data.count, signedOffset) + } + guard written >= 0 else { + throw HostFSError.io("pwrite: errno \(errno)") + } + return written + } + + public func fsync(handle fd: Int32) throws { + guard !readOnly else { throw HostFSError.readOnly } + guard Darwin.fsync(fd) == 0 else { + throw HostFSError.io("fsync: errno \(errno)") + } + } + + public func truncate(handle fd: Int32, size: UInt64) throws { + guard !readOnly else { throw HostFSError.readOnly } + guard let signedSize = off_t(exactly: size) else { + throw HostFSError.invalidName("truncate size") + } + guard ftruncate(fd, signedSize) == 0 else { + throw HostFSError.io("ftruncate: errno \(errno)") + } + } + + public func truncate(nodeID: UInt64, size: UInt64) throws { + guard !readOnly else { throw HostFSError.readOnly } + let node = try node(for: nodeID) + guard node.attributes.isRegularFile else { + throw HostFSError.notRegularFile(nodeID) + } + guard let signedSize = off_t(exactly: size) else { + throw HostFSError.invalidName("truncate size") + } + let fd = openat(rootFD, cPath(node.relativePath), O_WRONLY | O_NOFOLLOW | O_CLOEXEC) + guard fd >= 0 else { + if errno == ELOOP { throw HostFSError.permissionDenied(node.relativePath) } + throw HostFSError.io("openat truncate \(node.relativePath): errno \(errno)") + } + defer { Darwin.close(fd) } + guard ftruncate(fd, signedSize) == 0 else { + throw HostFSError.io("ftruncate: errno \(errno)") + } + } + + public func close(handle fd: Int32) { + Darwin.close(fd) + } + + public func createFile(parent: UInt64, name: String, mode: UInt16 = 0o644) throws -> HostFSEntry { + guard !readOnly else { throw HostFSError.readOnly } + try validateComponent(name) + try requireVisible(name) + let parentNode = try node(for: parent) + guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } + let relative = join(parentNode.relativePath, name) + let fd = openat(rootFD, cPath(relative), O_CREAT | O_EXCL | O_RDWR | O_NOFOLLOW | O_CLOEXEC, mode_t(mode)) + guard fd >= 0 else { + throw HostFSError.io("create \(relative): errno \(errno)") + } + Darwin.close(fd) + return try lookup(parent: parent, name: name) + } + + public func mkdir(parent: UInt64, name: String, mode: UInt16 = 0o755) throws -> HostFSEntry { + guard !readOnly else { throw HostFSError.readOnly } + try validateComponent(name) + try requireVisible(name) + let parentNode = try node(for: parent) + guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } + let relative = join(parentNode.relativePath, name) + guard mkdirat(rootFD, cPath(relative), mode_t(mode)) == 0 else { + throw HostFSError.io("mkdir \(relative): errno \(errno)") + } + return try lookup(parent: parent, name: name) + } + + public func unlink(parent: UInt64, name: String) throws { + guard !readOnly else { throw HostFSError.readOnly } + try validateComponent(name) + try requireVisible(name) + let parentNode = try node(for: parent) + guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } + let relative = join(parentNode.relativePath, name) + guard unlinkat(rootFD, cPath(relative), 0) == 0 else { + throw HostFSError.io("unlink \(relative): errno \(errno)") + } + forget(relativePath: relative) + } + + public func rmdir(parent: UInt64, name: String) throws { + guard !readOnly else { throw HostFSError.readOnly } + try validateComponent(name) + try requireVisible(name) + let parentNode = try node(for: parent) + guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } + let relative = join(parentNode.relativePath, name) + guard unlinkat(rootFD, cPath(relative), AT_REMOVEDIR) == 0 else { + throw HostFSError.io("rmdir \(relative): errno \(errno)") + } + forget(relativePath: relative) + } + + public func rename(parent: UInt64, name: String, newParent: UInt64, newName: String) throws -> HostFSEntry { + guard !readOnly else { throw HostFSError.readOnly } + try validateComponent(name) + try validateComponent(newName) + try requireVisible(name) + try requireVisible(newName) + let parentNode = try node(for: parent) + let newParentNode = try node(for: newParent) + guard parentNode.attributes.isDirectory else { throw HostFSError.notDirectory(parent) } + guard newParentNode.attributes.isDirectory else { throw HostFSError.notDirectory(newParent) } + let oldRelative = join(parentNode.relativePath, name) + let newRelative = join(newParentNode.relativePath, newName) + guard renameat(rootFD, cPath(oldRelative), rootFD, cPath(newRelative)) == 0 else { + throw HostFSError.io("rename \(oldRelative): errno \(errno)") + } + forget(relativePath: oldRelative) + return try lookup(parent: newParent, name: newName) + } + + public func readdirplus(nodeID: UInt64) throws -> [HostFSEntry] { + let node = try node(for: nodeID) + guard node.attributes.isDirectory else { + throw HostFSError.notDirectory(nodeID) + } + let absolute = node.relativePath.isEmpty ? rootPath : rootPath + "/" + node.relativePath + guard let names = try? FileManager.default.contentsOfDirectory(atPath: absolute) else { + throw HostFSError.notFound(node.relativePath) + } + return try names.sorted() + .filter { !hiddenNames.contains($0) } + .map { try lookup(parent: nodeID, name: $0) } + } + + public func statfs() throws -> HostFSStat { + var st = Darwin.statfs() + guard Darwin.fstatfs(rootFD, &st) == 0 else { + throw HostFSError.io("fstatfs: errno \(errno)") + } + let blockSize = UInt64(st.f_bsize) + let blocks = UInt64(st.f_blocks) + let blocksFree = UInt64(st.f_bfree) + let blocksAvailable = UInt64(st.f_bavail) + let files = UInt64(st.f_files) + let filesFree = UInt64(st.f_ffree) + let nameMax = UInt32(NAME_MAX) + return HostFSStat( + blockSize: blockSize, + blocks: blocks, + blocksFree: blocksFree, + blocksAvailable: blocksAvailable, + files: files, + filesFree: filesFree, + nameMax: nameMax + ) + } + + public func setXattr(handle fd: Int32, name: String, value: [UInt8]) throws { + guard !readOnly else { throw HostFSError.readOnly } + let result = value.withUnsafeBytes { raw in + fsetxattr(fd, name, raw.baseAddress, value.count, 0, 0) + } + guard result == 0 else { + throw HostFSError.io("fsetxattr \(name): errno \(errno)") + } + } + + public func getXattr(handle fd: Int32, name: String) throws -> [UInt8] { + let size = fgetxattr(fd, name, nil, 0, 0, 0) + guard size >= 0 else { + throw HostFSError.io("fgetxattr \(name): errno \(errno)") + } + var data = [UInt8](repeating: 0, count: size) + let read = data.withUnsafeMutableBytes { raw in + fgetxattr(fd, name, raw.baseAddress, size, 0, 0) + } + guard read >= 0 else { + throw HostFSError.io("fgetxattr \(name): errno \(errno)") + } + return data + } + + public func listXattrs(handle fd: Int32) throws -> [String] { + let size = flistxattr(fd, nil, 0, 0) + guard size >= 0 else { + throw HostFSError.io("flistxattr: errno \(errno)") + } + guard size > 0 else { return [] } + var data = [CChar](repeating: 0, count: size) + let read = flistxattr(fd, &data, size, 0) + guard read >= 0 else { + throw HostFSError.io("flistxattr: errno \(errno)") + } + return data.split(separator: 0).map { String(cString: Array($0) + [0]) }.sorted() + } + + private func node(for id: UInt64) throws -> Node { + guard let node = lock.withLock({ nodes[id] }) else { + throw HostFSError.notFound("node \(id)") + } + return node + } + + private func validateComponent(_ name: String) throws { + guard !name.isEmpty, name != ".", name != "..", !name.contains("/") else { + throw HostFSError.invalidName(name) + } + } + + private func requireVisible(_ name: String) throws { + guard !hiddenNames.contains(name) else { + throw HostFSError.notFound(name) + } + } + + private func forget(relativePath: String) { + lock.withLock { + let prefix = relativePath + "/" + var affectedPaths = [relativePath] + for path in idsByRelativePath.keys where path.hasPrefix(prefix) { + affectedPaths.append(path) + } + for path in affectedPaths { + guard let ids = idsByRelativePath.removeValue(forKey: path) else { continue } + for id in ids { + guard let node = nodes.removeValue(forKey: id) else { continue } + idsByFileKey.removeValue(forKey: node.fileKey) + } + } + } + } + + private func join(_ parent: String, _ name: String) -> String { + parent.isEmpty ? name : parent + "/" + name + } + + private func cPath(_ relative: String) -> [CChar] { + relative.isEmpty ? [0] : Array(relative.utf8CString) + } + + private static func attributes(from st: stat, nodeID: UInt64, uid: UInt32, gid: UInt32) -> HostFSAttributes { + HostFSAttributes( + nodeID: nodeID, + mode: UInt32(st.st_mode), + size: UInt64(max(0, st.st_size)), + uid: uid, + gid: gid, + atimeSeconds: Int64(st.st_atimespec.tv_sec), + mtimeSeconds: Int64(st.st_mtimespec.tv_sec), + ctimeSeconds: Int64(st.st_ctimespec.tv_sec), + atimeNsec: UInt32(truncatingIfNeeded: st.st_atimespec.tv_nsec), + mtimeNsec: UInt32(truncatingIfNeeded: st.st_mtimespec.tv_nsec), + ctimeNsec: UInt32(truncatingIfNeeded: st.st_ctimespec.tv_nsec) + ) + } +} + +private struct FileKey: Hashable, Sendable { + var device: UInt64 + var inode: UInt64 + + init(_ st: stat) { + self.device = UInt64(st.st_dev) + self.inode = UInt64(st.st_ino) + } +} + +private extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift new file mode 100644 index 0000000..8218183 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFSEventRelay.swift @@ -0,0 +1,192 @@ +import CoreServices +import Foundation + +public struct FSEventBatchParams: Codable, Sendable, Equatable { + public var paths: [String] + + public init(paths: [String]) { + self.paths = paths + } +} + +public struct FSEventBatchResult: Decodable, Sendable, Equatable { + public var touched: Int +} + +public struct HostFSEventShare: Sendable, Equatable { + public var hostRoot: String + public var guestRoot: String + + public init(hostRoot: String, guestRoot: String) { + self.hostRoot = URL(fileURLWithPath: hostRoot).standardizedFileURL.path + self.guestRoot = guestRoot + } +} + +public final class FSEventBatcher: @unchecked Sendable { + private let shares: [HostFSEventShare] + private let send: @Sendable ([String]) async -> Void + private let lock = NSLock() + private var pending = Set() + + public init(shares: [HostFSEventShare], send: @escaping @Sendable ([String]) async -> Void) { + self.shares = shares + self.send = send + } + + public func enqueue(hostPaths: [String]) { + let guestPaths = hostPaths.compactMap(mapHostPathToGuest) + guard !guestPaths.isEmpty else { return } + lock.withLock { + pending.formUnion(guestPaths) + } + } + + public func flushNow() async { + let paths = lock.withLock { () -> [String] in + let paths = pending.sorted() + pending.removeAll() + return paths + } + guard !paths.isEmpty else { return } + await send(paths) + } + + public var hasPending: Bool { + lock.withLock { !pending.isEmpty } + } + + public func mapHostPathToGuest(_ path: String) -> String? { + let normalized = URL(fileURLWithPath: path).standardizedFileURL.path + for share in shares { + if normalized == share.hostRoot { + return share.guestRoot + } + let prefix = share.hostRoot.hasSuffix("/") ? share.hostRoot : share.hostRoot + "/" + guard normalized.hasPrefix(prefix) else { continue } + let relative = String(normalized.dropFirst(prefix.count)) + guard !relative.isEmpty else { return share.guestRoot } + return share.guestRoot + "/" + relative + } + return nil + } +} + +public final class HostFSEventRelay: @unchecked Sendable { + public typealias SendBatch = @Sendable ([String]) async -> Void + + private let shares: [HostFSEventShare] + private let batcher: FSEventBatcher + private let debounceNanoseconds: UInt64 + private let queue = DispatchQueue(label: "dev.dory.hostfs.fsevents") + private let lock = NSLock() + private var stream: FSEventStreamRef? + private var callbackBox: CallbackBox? + private var flushScheduled = false + + public init( + shares: [HostFSEventShare], + debounceMilliseconds: UInt64 = 50, + send: @escaping SendBatch + ) { + self.shares = shares + self.batcher = FSEventBatcher(shares: shares, send: send) + self.debounceNanoseconds = debounceMilliseconds * 1_000_000 + } + + deinit { + stop() + } + + public func start() { + guard stream == nil, !shares.isEmpty else { return } + let paths = shares.map(\.hostRoot) as CFArray + let box = CallbackBox(relay: self) + callbackBox = box + var context = FSEventStreamContext( + version: 0, + info: Unmanaged.passUnretained(box).toOpaque(), + retain: nil, + release: nil, + copyDescription: nil + ) + guard let created = FSEventStreamCreate( + nil, + { _, info, count, eventPaths, _, _ in + guard let info else { return } + let box = Unmanaged.fromOpaque(info).takeUnretainedValue() + let pathsPointer = unsafeBitCast(eventPaths, to: NSArray.self) + let paths = (0.. Bool in + guard !flushScheduled else { return false } + flushScheduled = true + return true + } + guard shouldSchedule else { return } + Task.detached { [weak self] in + guard let self else { return } + try? await Task.sleep(nanoseconds: debounceNanoseconds) + await self.batcher.flushNow() + let shouldReschedule = self.lock.withLock { () -> Bool in + self.flushScheduled = false + return self.batcher.hasPending + } + if shouldReschedule { + self.scheduleFlush() + } + } + } +} + +private final class CallbackBox { + weak var relay: HostFSEventRelay? + + init(relay: HostFSEventRelay) { + self.relay = relay + } +} + +public extension AgentChannel { + func sendFSEventBatch(paths: [String]) async throws -> FSEventBatchResult { + try await call("fsevents.batch", FSEventBatchParams(paths: paths)) + } +} + +private extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GICv3MMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GICv3MMIO.swift new file mode 100644 index 0000000..a337c86 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GICv3MMIO.swift @@ -0,0 +1,98 @@ +import Foundation +import Hypervisor + +/// Bridges guest MMIO in the GIC distributor and redistributor windows to the in-kernel GIC's +/// register API. The interrupt machinery itself (prioritization, CPU interface, timer PPIs) runs +/// inside Hypervisor.framework; only the memory-mapped configuration surface passes through here. +/// Offsets the framework does not model read as zero and ignore writes, which matches RAZ/WI +/// behavior for optional GICv3 registers (WAKER, CTLR sleep bits, LPI tables). +public final class GICDistributorMMIO: MMIODevice { + public let baseAddress: UInt64 + public let size: UInt64 + + public init(baseAddress: UInt64, size: UInt64) { + self.baseAddress = baseAddress + self.size = size + } + + public func read(offset: UInt64, width: Int) -> UInt64 { + var value: UInt64 = 0 + if hv_gic_get_distributor_reg(hv_gic_distributor_reg_t(UInt16(truncatingIfNeeded: offset)), &value) == HV_SUCCESS { + return value + } + if offset & 0x4 != 0 { + var aligned: UInt64 = 0 + if hv_gic_get_distributor_reg(hv_gic_distributor_reg_t(UInt16(truncatingIfNeeded: offset - 4)), &aligned) == HV_SUCCESS { + return aligned >> 32 + } + } + return 0 + } + + public func write(offset: UInt64, value: UInt64, width: Int) { + let register = hv_gic_distributor_reg_t(UInt16(truncatingIfNeeded: offset)) + if hv_gic_set_distributor_reg(register, value) == HV_SUCCESS { return } + if offset & 0x4 != 0, width == 4 { + let alignedRegister = hv_gic_distributor_reg_t(UInt16(truncatingIfNeeded: offset - 4)) + var current: UInt64 = 0 + if hv_gic_get_distributor_reg(alignedRegister, ¤t) == HV_SUCCESS { + let merged = (current & 0xFFFF_FFFF) | (value << 32) + _ = hv_gic_set_distributor_reg(alignedRegister, merged) + } + } + } +} + +public final class GICRedistributorMMIO: MMIODevice { + public let baseAddress: UInt64 + public let size: UInt64 + public let stride: UInt64 + private var vcpuHandles: [hv_vcpu_t?] = [] + + public init(baseAddress: UInt64, size: UInt64, stride: UInt64) { + self.baseAddress = baseAddress + self.size = size + self.stride = stride + } + + /// Registration completes before the boot CPU starts, so MMIO reads never race these writes. + public func setHandle(_ handle: hv_vcpu_t, at frameIndex: Int) { + while vcpuHandles.count <= frameIndex { vcpuHandles.append(nil) } + vcpuHandles[frameIndex] = handle + } + + public func read(offset: UInt64, width: Int) -> UInt64 { + guard let (vcpu, registerOffset) = resolve(offset) else { return 0 } + var value: UInt64 = 0 + if hv_gic_get_redistributor_reg(vcpu, hv_gic_redistributor_reg_t(UInt32(registerOffset)), &value) == HV_SUCCESS { + return value + } + if registerOffset & 0x4 != 0 { + var aligned: UInt64 = 0 + if hv_gic_get_redistributor_reg(vcpu, hv_gic_redistributor_reg_t(UInt32(registerOffset - 4)), &aligned) == HV_SUCCESS { + return aligned >> 32 + } + } + return 0 + } + + public func write(offset: UInt64, value: UInt64, width: Int) { + guard let (vcpu, registerOffset) = resolve(offset) else { return } + let register = hv_gic_redistributor_reg_t(UInt32(registerOffset)) + if hv_gic_set_redistributor_reg(vcpu, register, value) == HV_SUCCESS { return } + if registerOffset & 0x4 != 0, width == 4 { + let alignedRegister = hv_gic_redistributor_reg_t(UInt32(registerOffset - 4)) + var current: UInt64 = 0 + if hv_gic_get_redistributor_reg(vcpu, alignedRegister, ¤t) == HV_SUCCESS { + let merged = (current & 0xFFFF_FFFF) | (value << 32) + _ = hv_gic_set_redistributor_reg(vcpu, alignedRegister, merged) + } + } + } + + private func resolve(_ offset: UInt64) -> (hv_vcpu_t, UInt64)? { + let index = Int(offset / stride) + guard index < vcpuHandles.count, let handle = vcpuHandles[index] else { return nil } + return (handle, offset % stride) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift new file mode 100644 index 0000000..cd54bcc --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/GuestMemory.swift @@ -0,0 +1,157 @@ +import Darwin +import Hypervisor +import Synchronization + +/// The VM's RAM: one anonymous mmap region in OUR address space, mapped into the guest at a fixed +/// physical base. Owning the pages is the entire point of dory-hv: reclaim is madvise on this +/// region, something Virtualization.framework structurally cannot offer (its guest RAM lives in +/// Apple's XPC process). +public final class GuestMemory: @unchecked Sendable { + public let guestBase: UInt64 + public let size: UInt64 + public let hostBase: UnsafeMutableRawPointer + public let releasedBytes = Atomic(0) + public let restoredBytes = Atomic(0) + + static let pageSize: UInt64 = 16384 + private let releasedPages: Mutex<[Bool]> + + public init(guestBase: UInt64, size: UInt64) throws { + guard size > 0, size % 16384 == 0 else { + throw VMError.invalidConfiguration("RAM size must be a positive multiple of 16KiB") + } + guard let region = mmap(nil, Int(size), PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0), + region != MAP_FAILED else { + throw VMError.outOfMemory("mmap of \(size) bytes failed: errno \(errno)") + } + self.guestBase = guestBase + self.size = size + self.hostBase = region + self.releasedPages = Mutex([Bool](repeating: false, count: Int(size / Self.pageSize))) + } + + deinit { + munmap(hostBase, Int(size)) + } + + public func mapIntoGuest() throws { + try hvCheck( + hv_vm_map(hostBase, guestBase, Int(size), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC)), + "hv_vm_map" + ) + } + + /// Returns a reported-free range to macOS. Stage-2 pins guest pages while mapped, so the + /// range is unmapped from the guest first, then marked reusable; the physical pages leave the + /// process footprint immediately. The guest gets the range back lazily via handleRAMFault. + @discardableResult + public func releaseRange(guestAddress: UInt64, length: UInt64) -> Bool { + guard contains(guestAddress, count: length), length > 0, + guestAddress % Self.pageSize == 0, length % Self.pageSize == 0 else { return false } + let first = Int((guestAddress - guestBase) / Self.pageSize) + let count = Int(length / Self.pageSize) + let host = hostBase.advanced(by: Int(guestAddress - guestBase)) + // The bitmap flip and the stage-2 unmap happen atomically under one lock, so a concurrent + // restorePage on another vCPU can never observe an unmapped-but-unmarked page (which it + // would misread as a genuine fault and crash). + return releasedPages.withLock { pages -> Bool in + guard hv_vm_unmap(guestAddress, Int(length)) == HV_SUCCESS else { return false } + _ = madvise(host, Int(length), MADV_FREE_REUSABLE) + for page in first.. Bool { + guard contains(guestAddress, count: 1) else { return false } + let pageStart = guestAddress & ~(Self.pageSize - 1) + let index = Int((pageStart - guestBase) / Self.pageSize) + let host = hostBase.advanced(by: Int(pageStart - guestBase)) + return releasedPages.withLock { pages -> Bool in + guard index < pages.count else { return false } + // Bit clear means a concurrent fault on the same page already remapped it (both + // observed the fault; whoever won the lock first did the mapping). A stage-2 RAM fault + // never occurs on a page we did not unmap, so treating this as already-mapped is safe + // and the guest's retry resolves it. + guard pages[index] else { return true } + _ = madvise(host, Int(Self.pageSize), MADV_FREE_REUSE) + guard hv_vm_map(host, pageStart, Int(Self.pageSize), hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC)) == HV_SUCCESS else { + return false + } + pages[index] = false + restoredBytes.add(Self.pageSize, ordering: .relaxed) + return true + } + } + + public func contains(_ address: UInt64, count: UInt64) -> Bool { + guard address >= guestBase else { return false } + let offset = address - guestBase + return offset <= size && count <= size - offset + } + + public func hostPointer(at guestAddress: UInt64, count: UInt64) throws -> UnsafeMutableRawPointer { + guard contains(guestAddress, count: count) else { + throw VMError.guestMemoryFault(address: guestAddress, count: count) + } + return hostBase.advanced(by: Int(guestAddress - guestBase)) + } + + public func read(_ type: T.Type, at guestAddress: UInt64) throws -> T { + let pointer = try hostPointer(at: guestAddress, count: UInt64(MemoryLayout.size)) + var value = T.zero + withUnsafeMutableBytes(of: &value) { destination in + destination.copyMemory(from: UnsafeRawBufferPointer(start: pointer, count: MemoryLayout.size)) + } + return T(littleEndian: value) + } + + public func write(_ value: T, at guestAddress: UInt64) throws { + let pointer = try hostPointer(at: guestAddress, count: UInt64(MemoryLayout.size)) + var little = value.littleEndian + withUnsafeBytes(of: &little) { source in + pointer.copyMemory(from: source.baseAddress!, byteCount: MemoryLayout.size) + } + } + + public func write(_ data: [UInt8], at guestAddress: UInt64) throws { + guard !data.isEmpty else { return } + let pointer = try hostPointer(at: guestAddress, count: UInt64(data.count)) + data.withUnsafeBytes { source in + pointer.copyMemory(from: source.baseAddress!, byteCount: data.count) + } + } + + public func readBytes(at guestAddress: UInt64, count: Int) throws -> [UInt8] { + guard count > 0 else { return [] } + let pointer = try hostPointer(at: guestAddress, count: UInt64(count)) + return [UInt8](UnsafeRawBufferPointer(start: pointer, count: count)) + } +} + +public enum VMError: Error, CustomStringConvertible { + case invalidConfiguration(String) + case outOfMemory(String) + case guestMemoryFault(address: UInt64, count: UInt64) + case bootFailure(String) + case unexpectedExit(String) + + public var description: String { + switch self { + case .invalidConfiguration(let message): return "invalid configuration: \(message)" + case .outOfMemory(let message): return "out of memory: \(message)" + case .guestMemoryFault(let address, let count): + return "guest memory fault: 0x\(String(address, radix: 16)) +\(count)" + case .bootFailure(let message): return "boot failure: \(message)" + case .unexpectedExit(let message): return "unexpected exit: \(message)" + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/HVError.swift b/Packages/ContainerizationEngine/Sources/DoryHV/HVError.swift new file mode 100644 index 0000000..7b1722d --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/HVError.swift @@ -0,0 +1,30 @@ +import Hypervisor + +public struct HVError: Error, CustomStringConvertible { + public let call: String + public let code: hv_return_t + + public var description: String { + "\(call) failed: \(Self.name(for: code)) (0x\(String(UInt32(bitPattern: code), radix: 16)))" + } + + private static func name(for code: hv_return_t) -> String { + switch Int(code) { + case HV_ERROR: return "HV_ERROR" + case HV_BUSY: return "HV_BUSY" + case HV_BAD_ARGUMENT: return "HV_BAD_ARGUMENT" + case HV_ILLEGAL_GUEST_STATE: return "HV_ILLEGAL_GUEST_STATE" + case HV_NO_RESOURCES: return "HV_NO_RESOURCES" + case HV_NO_DEVICE: return "HV_NO_DEVICE" + case HV_DENIED: return "HV_DENIED" + case HV_UNSUPPORTED: return "HV_UNSUPPORTED" + default: return "unknown" + } + } +} + +@inline(__always) +public func hvCheck(_ call: @autoclosure () -> hv_return_t, _ name: String) throws { + let code = call() + guard code == HV_SUCCESS else { throw HVError(call: name, code: code) } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift new file mode 100644 index 0000000..d825c6b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/KernelImage.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Loader for the arm64 Linux boot Image format (Documentation/arch/arm64/booting.rst). +public struct KernelImage { + public let data: Data + public let textOffset: UInt64 + public let imageSize: UInt64 + + private static let magicOffset = 56 + private static let magic: UInt32 = 0x644D_5241 // "ARM\x64" + + public init(contentsOf path: String) throws { + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + guard data.count > 64 else { + throw VMError.bootFailure("kernel image too small: \(data.count) bytes") + } + let magic = data.readLittleEndian(UInt32.self, at: Self.magicOffset) + guard magic == Self.magic else { + throw VMError.bootFailure("not an arm64 boot Image (magic 0x\(String(magic, radix: 16)))") + } + self.data = data + self.textOffset = data.readLittleEndian(UInt64.self, at: 8) + let declaredSize = data.readLittleEndian(UInt64.self, at: 16) + self.imageSize = max(declaredSize, UInt64(data.count)) + } + + /// Copies the image into guest RAM and returns the entry point. + public func load(into memory: GuestMemory) throws -> UInt64 { + let loadAddress = memory.guestBase + textOffset + guard memory.contains(loadAddress, count: imageSize) else { + throw VMError.bootFailure("kernel does not fit in guest RAM") + } + let destination = try memory.hostPointer(at: loadAddress, count: UInt64(data.count)) + data.withUnsafeBytes { source in + destination.copyMemory(from: source.baseAddress!, byteCount: data.count) + } + return loadAddress + } +} + +extension Data { + func readLittleEndian(_ type: T.Type, at offset: Int) -> T { + var value = T.zero + for byteIndex in 0...size { + let byte = self[startIndex + offset + byteIndex] + value |= T(truncatingIfNeeded: UInt64(byte) << (8 * UInt64(byteIndex))) + } + return value + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/LengthPrefixCodec.swift b/Packages/ContainerizationEngine/Sources/DoryHV/LengthPrefixCodec.swift new file mode 100644 index 0000000..2b6a06a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/LengthPrefixCodec.swift @@ -0,0 +1,35 @@ +import Foundation + +public enum LengthPrefixCodecError: Error, Equatable { + case frameTooLarge(Int) + case malformedPrefix +} + +public enum LengthPrefixCodec { + public static let prefixByteCount = 4 + + public static func encode(_ payload: [UInt8], maximumFrameBytes: Int) throws -> [UInt8] { + guard payload.count <= maximumFrameBytes else { + throw LengthPrefixCodecError.frameTooLarge(payload.count) + } + let length = UInt32(payload.count) + var frame = [UInt8]() + frame.reserveCapacity(prefixByteCount + payload.count) + frame.append(UInt8((length >> 24) & 0xFF)) + frame.append(UInt8((length >> 16) & 0xFF)) + frame.append(UInt8((length >> 8) & 0xFF)) + frame.append(UInt8(length & 0xFF)) + frame.append(contentsOf: payload) + return frame + } + + public static func decodeLength(_ prefix: [UInt8], maximumFrameBytes: Int) throws -> Int { + guard prefix.count == prefixByteCount else { throw LengthPrefixCodecError.malformedPrefix } + let length = (UInt32(prefix[0]) << 24) | (UInt32(prefix[1]) << 16) + | (UInt32(prefix[2]) << 8) | UInt32(prefix[3]) + guard length <= UInt32(maximumFrameBytes) else { + throw LengthPrefixCodecError.frameTooLarge(Int(length)) + } + return Int(length) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift new file mode 100644 index 0000000..0e13aa3 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MMIO.swift @@ -0,0 +1,44 @@ +/// A memory-mapped device the guest reaches through stage-2 data aborts. +public protocol MMIODevice: AnyObject { + var baseAddress: UInt64 { get } + var size: UInt64 { get } + func read(offset: UInt64, width: Int) -> UInt64 + func write(offset: UInt64, value: UInt64, width: Int) +} + +/// Routes guest data aborts to the owning device by physical address. +public final class MMIOBus { + private var devices: [MMIODevice] = [] + + public init() {} + + public func attach(_ device: MMIODevice) { + devices.append(device) + } + + public func device(for address: UInt64) -> (MMIODevice, UInt64)? { + for device in devices where address >= device.baseAddress && address < device.baseAddress + device.size { + return (device, address - device.baseAddress) + } + return nil + } +} + +/// Fields of an EC=0x24 (data abort from a lower EL) syndrome, valid when ISV is set. +public struct DataAbortInfo { + public let isValid: Bool + public let width: Int + public let registerIndex: Int + public let isWrite: Bool + public let signExtend: Bool + public let sixtyFourBit: Bool + + public init(syndrome: UInt64) { + self.isValid = (syndrome >> 24) & 1 == 1 + self.width = 1 << Int((syndrome >> 22) & 0b11) + self.signExtend = (syndrome >> 21) & 1 == 1 + self.registerIndex = Int((syndrome >> 16) & 0x1F) + self.sixtyFourBit = (syndrome >> 15) & 1 == 1 + self.isWrite = (syndrome >> 6) & 1 == 1 + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift new file mode 100644 index 0000000..0e01b9e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift @@ -0,0 +1,564 @@ +import Darwin +import Foundation +import Hypervisor + +/// Guest physical layout, modeled on QEMU's virt machine so every address is one Linux has been +/// booting on for a decade. +public enum GuestLayout { + // The in-kernel GIC sizes its redistributor region for the architectural vCPU maximum (32 MiB + // observed), so the UART and virtio windows sit safely above the whole span. + public static let gicDistributorBase: UInt64 = 0x0800_0000 + public static let gicRedistributorBase: UInt64 = 0x080A_0000 + public static let uartBase: UInt64 = 0x0C00_0000 + public static let uartIRQ: UInt32 = 1 // SPI number (intid 32 + 1) + public static let rtcBase: UInt64 = 0x0C09_0000 + public static let virtioBase: UInt64 = 0x0C10_0000 + public static let virtioSlotSize: UInt64 = 0x200 + public static let virtioFirstIRQ: UInt32 = 16 // SPI numbers 16... (intid 48...) + public static let ramBase: UInt64 = 0x8000_0000 + public static let dtbOffset: UInt64 = 256 << 20 + public static let daxWindowBase: UInt64 = 0xC_0000_0000 +} + +public struct MachineConfiguration { + public var kernelPath: String + public var commandLine: String + public var memoryBytes: UInt64 + public var cpuCount: Int + + public init(kernelPath: String, commandLine: String, memoryBytes: UInt64, cpuCount: Int) { + self.kernelPath = kernelPath + self.commandLine = commandLine + self.memoryBytes = memoryBytes + self.cpuCount = cpuCount + } +} + +public enum GuestStopReason: Sendable { + case powerOff + case reset + case crash(String) +} + +/// The virtual machine: RAM, GIC, devices, and the vCPU threads. SMP: secondaries are created +/// eagerly, parked, and released by PSCI CPU_ON. Thread-shared state is guarded by +/// `teamCondition`; devices serialize their own guest-facing surfaces. +public final class Machine: @unchecked Sendable { + public let configuration: MachineConfiguration + public let memory: GuestMemory + public let bus = MMIOBus() + private var entryPoint: UInt64 = 0 + private var dtbAddress: UInt64 = 0 + private var sysregLogCount = 0 + private let redistributorMMIO: GICRedistributorMMIO + + public init(configuration: MachineConfiguration) throws { + try hvCheck(hv_vm_create(nil), "hv_vm_create") + self.configuration = configuration + self.memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: configuration.memoryBytes) + try memory.mapIntoGuest() + try Self.createGIC() + + var redistributorStride = 0 + try hvCheck(hv_gic_get_redistributor_size(&redistributorStride), "hv_gic_get_redistributor_size") + self.redistributorMMIO = GICRedistributorMMIO( + baseAddress: GuestLayout.gicRedistributorBase, + size: try Self.gicRedistributorRegionSize(), + stride: UInt64(redistributorStride) + ) + bus.attach(GICDistributorMMIO( + baseAddress: GuestLayout.gicDistributorBase, + size: try Self.gicDistributorSize() + )) + bus.attach(redistributorMMIO) + } + + deinit { + hv_vm_destroy() + } + + private static func createGIC() throws { + let config = hv_gic_config_create() + try hvCheck(hv_gic_config_set_distributor_base(config, GuestLayout.gicDistributorBase), "gic set distributor base") + try hvCheck(hv_gic_config_set_redistributor_base(config, GuestLayout.gicRedistributorBase), "gic set redistributor base") + try hvCheck(hv_gic_create(config), "hv_gic_create") + } + + public static func gicDistributorSize() throws -> UInt64 { + var size = 0 + try hvCheck(hv_gic_get_distributor_size(&size), "hv_gic_get_distributor_size") + return UInt64(size) + } + + public static func gicRedistributorRegionSize() throws -> UInt64 { + var size = 0 + try hvCheck(hv_gic_get_redistributor_region_size(&size), "hv_gic_get_redistributor_region_size") + return UInt64(size) + } + + public static func reservedIntid(_ interrupt: hv_gic_intid_t) throws -> UInt32 { + var intid: UInt32 = 0 + try hvCheck(hv_gic_get_intid(interrupt, &intid), "hv_gic_get_intid") + return intid + } + + /// Pulses a shared peripheral interrupt (declared edge-triggered in the DTB). + public func raiseSPI(_ spi: UInt32) { + let intid = 32 + spi + _ = hv_gic_set_spi(intid, true) + } + + public func loadBootPayload() throws { + let kernel = try KernelImage(contentsOf: configuration.kernelPath) + entryPoint = try kernel.load(into: memory) + dtbAddress = GuestLayout.ramBase + GuestLayout.dtbOffset + guard kernel.textOffset + kernel.imageSize < GuestLayout.dtbOffset else { + throw VMError.bootFailure("kernel image overlaps DTB placement") + } + let dtb = try buildDeviceTree() + try memory.write(dtb, at: dtbAddress) + } + + private func buildDeviceTree() throws -> [UInt8] { + let gicPhandle: UInt32 = 1 + let clockPhandle: UInt32 = 2 + let virtualTimer = try Self.reservedIntid(HV_GIC_INT_EL1_VIRTUAL_TIMER) + let physicalTimer = try Self.reservedIntid(HV_GIC_INT_EL1_PHYSICAL_TIMER) + let hypTimer = try Self.reservedIntid(HV_GIC_INT_EL2_PHYSICAL_TIMER) + let distributorSize = try Self.gicDistributorSize() + let redistributorSize = try Self.gicRedistributorRegionSize() + + let fdt = FDTBuilder() + fdt.beginNode("") + fdt.property("compatible", string: "linux,dummy-virt") + fdt.property("#address-cells", cells: [2]) + fdt.property("#size-cells", cells: [2]) + fdt.property("interrupt-parent", cells: [gicPhandle]) + + fdt.beginNode("chosen") + fdt.property("bootargs", string: configuration.commandLine) + fdt.property("stdout-path", string: "/pl011@\(String(GuestLayout.uartBase, radix: 16))") + fdt.endNode() + + fdt.beginNode("memory@\(String(GuestLayout.ramBase, radix: 16))") + fdt.property("device_type", string: "memory") + fdt.property("reg", cells64: [GuestLayout.ramBase, configuration.memoryBytes]) + fdt.endNode() + + fdt.beginNode("cpus") + fdt.property("#address-cells", cells: [1]) + fdt.property("#size-cells", cells: [0]) + for cpu in 0.. GuestStopReason { + let count = max(1, configuration.cpuCount) + teamHandles = Array(repeating: nil, count: count) + secondaryStarts = Array(repeating: nil, count: count) + cpuStarted = Array(repeating: false, count: count) + cpuStarted[0] = true + + for index in 1.. hv_vm_destroy) never races a live vCPU thread. + stopAll(stopReason ?? .powerOff) + teamCondition.lock() + while finishedSecondaries < count - 1 { + teamCondition.wait() + } + defer { teamCondition.unlock() } + return stopReason ?? .crash("boot CPU exited without a stop reason") + } + + private func cpuMain(index: Int) { + defer { + if index != 0 { + teamCondition.lock() + finishedSecondaries += 1 + teamCondition.broadcast() + teamCondition.unlock() + } + } + do { + let vcpu = try VCPU() + try vcpu.writeSystem(HV_SYS_REG_MPIDR_EL1, 0x8000_0000 | UInt64(index)) + register(vcpu: vcpu, index: index) + + if index == 0 { + try vcpu.write(HV_REG_CPSR, 0x3C5) + try vcpu.write(HV_REG_PC, entryPoint) + try vcpu.write(HV_REG_X0, dtbAddress) + try vcpu.write(HV_REG_X1, 0) + try vcpu.write(HV_REG_X2, 0) + try vcpu.write(HV_REG_X3, 0) + } else { + guard let start = parkUntilStarted(index: index) else { return } + try vcpu.write(HV_REG_CPSR, 0x3C5) + try vcpu.write(HV_REG_PC, start.entry) + try vcpu.write(HV_REG_X0, start.context) + } + + runLoop(vcpu: vcpu) + } catch { + stopAll(.crash("cpu\(index) failed: \(error)")) + } + } + + private func register(vcpu: VCPU, index: Int) { + // Map this vCPU to its redistributor frame by the base the GIC actually assigned it, + // rather than assuming creation order. + var redistributorBase: hv_ipa_t = 0 + var frameIndex = index + if hv_gic_get_redistributor_base(vcpu.handle, &redistributorBase) == HV_SUCCESS, + redistributorMMIO.stride > 0 { + frameIndex = Int((redistributorBase - GuestLayout.gicRedistributorBase) / redistributorMMIO.stride) + } + teamCondition.lock() + teamHandles[index] = vcpu.handle + redistributorMMIO.setHandle(vcpu.handle, at: frameIndex) + if index != 0 { registeredCPUs += 1 } + teamCondition.broadcast() + teamCondition.unlock() + } + + private func parkUntilStarted(index: Int) -> (entry: UInt64, context: UInt64)? { + teamCondition.lock() + defer { teamCondition.unlock() } + while secondaryStarts[index] == nil, stopReason == nil { + teamCondition.wait() + } + return secondaryStarts[index] + } + + private func stopAll(_ reason: GuestStopReason) { + teamCondition.lock() + if stopReason == nil { stopReason = reason } + // Cancel running vCPUs exactly once: a second pass could touch a handle a finished thread + // has already destroyed. + var handles: [hv_vcpu_t] = [] + if !vcpusExited { + vcpusExited = true + handles = teamHandles.compactMap { $0 } + } + teamCondition.broadcast() + teamCondition.unlock() + if !handles.isEmpty { + hv_vcpus_exit(&handles, UInt32(handles.count)) + } + } + + private func startSecondary(mpidr: UInt64, entry: UInt64, context: UInt64) -> Int64 { + let index = Int(mpidr & 0xFF) + teamCondition.lock() + defer { teamCondition.unlock() } + guard index > 0, index < cpuStarted.count else { return -2 } // INVALID_PARAMETERS + guard !cpuStarted[index] else { return -4 } // ALREADY_ON + cpuStarted[index] = true + secondaryStarts[index] = (entry: entry, context: context) + teamCondition.broadcast() + return 0 + } + + private func runLoop(vcpu: VCPU) { + while true { + teamCondition.lock() + let stopped = stopReason != nil + teamCondition.unlock() + if stopped { return } + + do { + let event = try vcpu.run() + switch event { + case .canceled: + return + case .vtimerActivated: + // With the in-kernel GIC the timer PPI is delivered by the GIC itself; unmask + // and continue so the vtimer can fire again. + try vcpu.setVTimerMask(false) + case .exception(let syndrome, _, let physicalAddress): + if let stop = try handleException( + vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress + ) { + stopAll(stop) + return + } + case .unknown(let raw): + stopAll(.crash("unknown exit reason \(raw)")) + return + } + } catch { + stopAll(.crash("\(error)")) + return + } + } + } + + private func handleException(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws -> GuestStopReason? { + guard let exceptionClass = ExceptionClass(syndrome: syndrome) else { + let pc = try vcpu.read(HV_REG_PC) + return .crash("unhandled exception class \(syndrome >> 26), syndrome 0x\(String(syndrome, radix: 16)), pc 0x\(String(pc, radix: 16))") + } + switch exceptionClass { + case .dataAbortLowerEL: + try handleMMIO(vcpu: vcpu, syndrome: syndrome, physicalAddress: physicalAddress) + return nil + case .instructionAbortLowerEL: + guard restoreIfReleasedRAM(physicalAddress) else { + return .crash("instruction abort outside RAM at pa 0x\(String(physicalAddress, radix: 16))") + } + return nil + case .hvc64: + // HVC returns with PC already past the instruction; unknown hypercalls get + // SMCCC NOT_SUPPORTED. + try vcpu.write(HV_REG_X0, UInt64(bitPattern: -1)) + return nil + case .smc64: + let result = try handleSMC(vcpu: vcpu) + try advancePC(vcpu) + return result + case .systemRegisterTrap: + try handleSystemRegisterTrap(vcpu: vcpu, syndrome: syndrome) + try advancePC(vcpu) + return nil + } + } + + private func handleMMIO(vcpu: VCPU, syndrome: UInt64, physicalAddress: UInt64) throws { + if restoreIfReleasedRAM(physicalAddress) { return } + let abort = DataAbortInfo(syndrome: syndrome) + guard abort.isValid else { + let pc = try vcpu.read(HV_REG_PC) + throw VMError.unexpectedExit("data abort without syndrome info at pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") + } + guard let (device, offset) = bus.device(for: physicalAddress) else { + let pc = try vcpu.read(HV_REG_PC) + throw VMError.unexpectedExit("guest touched unmapped pa 0x\(String(physicalAddress, radix: 16)), pc 0x\(String(pc, radix: 16))") + } + if abort.isWrite { + let value = abort.registerIndex == 31 ? 0 : try vcpu.read(registerFor(abort.registerIndex)) + device.write(offset: offset, value: truncate(value, width: abort.width), width: abort.width) + } else { + var value = device.read(offset: offset, width: abort.width) + value = truncate(value, width: abort.width) + if abort.signExtend { + value = signExtend(value, width: abort.width, to64: abort.sixtyFourBit) + } else if !abort.sixtyFourBit { + value &= 0xFFFF_FFFF + } + if abort.registerIndex != 31 { + try vcpu.write(registerFor(abort.registerIndex), value) + } + } + try advancePC(vcpu) + } + + private func handleSMC(vcpu: VCPU) throws -> GuestStopReason? { + let function = UInt32(truncatingIfNeeded: try vcpu.read(HV_REG_X0)) + switch function { + case PSCI.version: + try vcpu.write(HV_REG_X0, 0x0001_0000) + case PSCI.features: + let queried = UInt32(truncatingIfNeeded: try vcpu.read(HV_REG_X1)) + let supported: Set = [PSCI.version, PSCI.features, PSCI.systemOff, PSCI.systemReset, PSCI.cpuOn, PSCI.migrateInfoType] + try vcpu.write(HV_REG_X0, supported.contains(queried) ? 0 : UInt64(bitPattern: -1)) + case PSCI.migrateInfoType: + try vcpu.write(HV_REG_X0, 2) // migration not required + case PSCI.systemOff: + return .powerOff + case PSCI.systemReset: + return .reset + case PSCI.cpuOn: + let target = try vcpu.read(HV_REG_X1) + let entry = try vcpu.read(HV_REG_X2) + let context = try vcpu.read(HV_REG_X3) + let result = startSecondary(mpidr: target, entry: entry, context: context) + try vcpu.write(HV_REG_X0, UInt64(bitPattern: Int64(result))) + default: + try vcpu.write(HV_REG_X0, UInt64(bitPattern: -1)) + } + return nil + } + + private func handleSystemRegisterTrap(vcpu: VCPU, syndrome: UInt64) throws { + // RAZ/WI for trapped system registers the hardware does not virtualize (debug, PMU). + let isRead = syndrome & 1 == 1 + let registerIndex = Int((syndrome >> 5) & 0x1F) + if sysregLogCount < 8 { + sysregLogCount += 1 + let encoding = String(format: "op0=%d op1=%d crn=%d crm=%d op2=%d", + Int((syndrome >> 20) & 0b11), Int((syndrome >> 14) & 0b111), + Int((syndrome >> 10) & 0b1111), Int((syndrome >> 1) & 0b1111), + Int((syndrome >> 17) & 0b111)) + FileHandle.standardError.write(Data("dory-hv: sysreg trap (\(isRead ? "read" : "write")) \(encoding), RAZ/WI\n".utf8)) + } + if isRead && registerIndex != 31 { + try vcpu.write(registerFor(registerIndex), 0) + } + } + + /// A fault inside the RAM window MIGHT be the guest touching a page that free page reporting + /// returned to macOS. restorePage remaps it and returns true; if the page was never released + /// this returns false, so a genuine guest fault falls through to the crash path with a + /// diagnostic instead of an unkillable refault loop. + private func restoreIfReleasedRAM(_ physicalAddress: UInt64) -> Bool { + memory.restorePage(guestAddress: physicalAddress) + } + + private func advancePC(_ vcpu: VCPU) throws { + let pc = try vcpu.read(HV_REG_PC) + try vcpu.write(HV_REG_PC, pc + 4) + } + + private func registerFor(_ index: Int) -> hv_reg_t { + hv_reg_t(HV_REG_X0.rawValue + UInt32(index)) + } + + private func truncate(_ value: UInt64, width: Int) -> UInt64 { + switch width { + case 1: return value & 0xFF + case 2: return value & 0xFFFF + case 4: return value & 0xFFFF_FFFF + default: return value + } + } + + private func signExtend(_ value: UInt64, width: Int, to64: Bool) -> UInt64 { + let bits = width * 8 + let signBit = UInt64(1) << (bits - 1) + var extended = value + if value & signBit != 0 { + extended |= ~((UInt64(1) << bits) - 1) + } + return to64 ? extended : extended & 0xFFFF_FFFF + } +} + +enum PSCI { + static let version: UInt32 = 0x8400_0000 + static let cpuOn: UInt32 = 0xC400_0003 + static let migrateInfoType: UInt32 = 0x8400_0006 + static let systemOff: UInt32 = 0x8400_0008 + static let systemReset: UInt32 = 0x8400_0009 + static let features: UInt32 = 0x8400_000A +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MachinePortWatcher.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MachinePortWatcher.swift new file mode 100644 index 0000000..fac4bb2 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MachinePortWatcher.swift @@ -0,0 +1,44 @@ +import Foundation + +public protocol MachinePortForwarding: Sendable { + func exposeMachinePort(_ port: UInt16) async -> Bool + func unexposeMachinePort(_ port: UInt16) async -> Bool +} + +public final class MachinePortWatcher: @unchecked Sendable { + private let channel: AgentChannel + private let forwarder: any MachinePortForwarding + private let log: @Sendable (String) -> Void + + public init( + channel: AgentChannel, + forwarder: any MachinePortForwarding, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.channel = channel + self.forwarder = forwarder + self.log = log + } + + @discardableResult + public func pollOnce() async throws -> AgentPortSnapshot { + let snapshot = try await channel.watchPorts() + for event in snapshot.added where event.isTCP { + if await forwarder.exposeMachinePort(event.port) { + log("machine port forward: exposed 127.0.0.1:\(event.port)") + } + } + for event in snapshot.removed where event.isTCP { + if await forwarder.unexposeMachinePort(event.port) { + log("machine port forward: released 127.0.0.1:\(event.port)") + } + } + return snapshot + } +} + +private extension AgentPortEvent { + var isTCP: Bool { + `protocol` == "tcp" || `protocol` == "tcp6" + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/MadviseProbe.swift b/Packages/ContainerizationEngine/Sources/DoryHV/MadviseProbe.swift new file mode 100644 index 0000000..74ab3d9 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/MadviseProbe.swift @@ -0,0 +1,57 @@ +import Darwin +import Foundation +import Hypervisor + +/// Diagnostic: measures which madvise advice actually releases physical pages, on a plain anon +/// mmap and on the same region after hv_vm_map. Prints resident/footprint before and after. +public enum MadviseProbe { + private static let megabytes = 512 + private static let madvZero: Int32 = 11 + + public static func run() throws { + try scenario(label: "plain mmap", mapIntoVM: false) + try scenario(label: "hv_vm_map", mapIntoVM: true) + } + + private static func scenario(label: String, mapIntoVM: Bool) throws { + let size = megabytes << 20 + guard let region = mmap(nil, size, PROT_READ | PROT_WRITE, MAP_ANON | MAP_PRIVATE, -1, 0), + region != MAP_FAILED else { + throw VMError.outOfMemory("mmap failed") + } + defer { munmap(region, size) } + + if mapIntoVM { + try hvCheck(hv_vm_create(nil), "hv_vm_create") + try hvCheck( + hv_vm_map(region, 0x8000_0000, size, hv_memory_flags_t(HV_MEMORY_READ | HV_MEMORY_WRITE | HV_MEMORY_EXEC)), + "hv_vm_map" + ) + } + defer { if mapIntoVM { hv_vm_destroy() } } + + memset(region, 0xA5, size) + report("\(label): after touching \(megabytes)MB") + + for (name, advice) in [("MADV_ZERO", madvZero), ("MADV_FREE_REUSABLE", MADV_FREE_REUSABLE), ("MADV_FREE", MADV_FREE)] { + let result = madvise(region, size, advice) + report("\(label): madvise(\(name)) -> \(result == 0 ? "ok" : "errno \(errno)")") + if result == 0 { break } + } + report("\(label): final") + } + + private static func report(_ label: String) { + var info = proc_taskinfo() + let size = Int32(MemoryLayout.size) + _ = proc_pidinfo(getpid(), PROC_PIDTASKINFO, 0, &info, size) + var vmInfo = task_vm_info_data_t() + var count = mach_msg_type_number_t(MemoryLayout.size / MemoryLayout.size) + let _ = withUnsafeMutablePointer(to: &vmInfo) { pointer in + pointer.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { rebound in + task_info(mach_task_self_, task_flavor_t(TASK_VM_INFO), rebound, &count) + } + } + print("\(label): resident \(info.pti_resident_size >> 20) MB, footprint \(UInt64(vmInfo.phys_footprint) >> 20) MB") + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift new file mode 100644 index 0000000..4593983 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PL011.swift @@ -0,0 +1,54 @@ +import Foundation + +/// ARM PrimeCell PL011 UART, transmit-only. Console output lands on the supplied sink; the guest +/// sees an always-empty receive FIFO and an always-ready transmit FIFO. +public final class PL011: MMIODevice { + public let baseAddress: UInt64 + public let size: UInt64 = 0x1000 + + private var control: UInt64 = 0x300 + private var lineControl: UInt64 = 0 + private var integerBaud: UInt64 = 0 + private var fractionalBaud: UInt64 = 0 + private var interruptMask: UInt64 = 0 + private var fifoLevel: UInt64 = 0x12 + private let sink: (UInt8) -> Void + + private static let peripheralID: [UInt64] = [0x11, 0x10, 0x14, 0x00] + private static let cellID: [UInt64] = [0x0D, 0xF0, 0x05, 0xB1] + + public init(baseAddress: UInt64, sink: @escaping (UInt8) -> Void) { + self.baseAddress = baseAddress + self.sink = sink + } + + public func read(offset: UInt64, width: Int) -> UInt64 { + switch offset { + case 0x00: return 0 + case 0x18: return 0x90 // FR: TXFE | RXFE + case 0x24: return integerBaud + case 0x28: return fractionalBaud + case 0x2C: return lineControl + case 0x30: return control + case 0x34: return fifoLevel + case 0x38: return interruptMask + case 0x3C, 0x40: return 0 // RIS, MIS + case 0xFE0...0xFEC: return Self.peripheralID[Int((offset - 0xFE0) / 4)] + case 0xFF0...0xFFC: return Self.cellID[Int((offset - 0xFF0) / 4)] + default: return 0 + } + } + + public func write(offset: UInt64, value: UInt64, width: Int) { + switch offset { + case 0x00: sink(UInt8(truncatingIfNeeded: value)) + case 0x24: integerBaud = value + case 0x28: fractionalBaud = value + case 0x2C: lineControl = value + case 0x30: control = value + case 0x34: fifoLevel = value + case 0x38: interruptMask = value + default: break // ICR and friends: write-ignored + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/PL031.swift b/Packages/ContainerizationEngine/Sources/DoryHV/PL031.swift new file mode 100644 index 0000000..fb5fac6 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/PL031.swift @@ -0,0 +1,27 @@ +import Foundation + +/// ARM PL031 real-time clock, read-only: hands the guest host wall-clock time so certificate +/// validation and image timestamps are correct without an NTP round trip. +public final class PL031: MMIODevice { + public let baseAddress: UInt64 + public let size: UInt64 = 0x1000 + + private static let peripheralID: [UInt64] = [0x31, 0x10, 0x14, 0x00] + private static let cellID: [UInt64] = [0x0D, 0xF0, 0x05, 0xB1] + + public init(baseAddress: UInt64) { + self.baseAddress = baseAddress + } + + public func read(offset: UInt64, width: Int) -> UInt64 { + switch offset { + case 0x00, 0x08: return UInt64(max(0, time(nil))) // DR, LR + case 0x0C: return 1 // CR: enabled + case 0xFE0...0xFEC: return Self.peripheralID[Int((offset - 0xFE0) / 4)] + case 0xFF0...0xFFC: return Self.cellID[Int((offset - 0xFF0) / 4)] + default: return 0 + } + } + + public func write(offset: UInt64, value: UInt64, width: Int) {} +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Smoke.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Smoke.swift new file mode 100644 index 0000000..eaff630 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Smoke.swift @@ -0,0 +1,35 @@ +import Hypervisor + +/// Milestone 1 gate: prove the process may create a VM under its code signature, map RAM it owns, +/// and execute guest instructions. Runs a two-instruction guest (mov x0, #42; hvc #0) and checks +/// the hypercall lands back here with the expected register state. +public enum HVSmoke { + public static func run() throws -> String { + try hvCheck(hv_vm_create(nil), "hv_vm_create") + defer { hv_vm_destroy() } + + let ramBase: UInt64 = 0x8000_0000 + let memory = try GuestMemory(guestBase: ramBase, size: 1 << 20) + try memory.mapIntoGuest() + + try memory.write(UInt32(0xD280_0540), at: ramBase) // mov x0, #42 + try memory.write(UInt32(0xD400_0002), at: ramBase + 4) // hvc #0 + + let vcpu = try VCPU() + try vcpu.write(HV_REG_CPSR, 0x3C5) // EL1h, DAIF masked + try vcpu.write(HV_REG_PC, ramBase) + + let event = try vcpu.run() + guard case .exception(let syndrome, _, _) = event else { + throw VMError.unexpectedExit("expected exception exit, got \(event)") + } + guard ExceptionClass(syndrome: syndrome) == .hvc64 else { + throw VMError.unexpectedExit("expected HVC trap, syndrome 0x\(String(syndrome, radix: 16))") + } + let x0 = try vcpu.read(HV_REG_X0) + guard x0 == 42 else { + throw VMError.unexpectedExit("guest x0 = \(x0), expected 42") + } + return "hv smoke passed: guest executed 2 instructions, hvc trapped, x0=42" + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift new file mode 100644 index 0000000..837e7a6 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift @@ -0,0 +1,514 @@ +import Darwin +import Foundation +import IOKit +import IOKit.usb +import IOUSBHost +import DoryHVUSBShim + +public struct HostUsbDeviceCandidate: Codable, Equatable, Sendable { + public var descriptor: UsbipDeviceDescriptor + public var vendorName: String? + public var productName: String? + public var serialNumber: String? + public var locationID: UInt32? + + public init( + descriptor: UsbipDeviceDescriptor, + vendorName: String? = nil, + productName: String? = nil, + serialNumber: String? = nil, + locationID: UInt32? = nil + ) { + self.descriptor = descriptor + self.vendorName = vendorName + self.productName = productName + self.serialNumber = serialNumber + self.locationID = locationID + } +} + +public enum HostUsbDiscoveryError: Error, Equatable, Sendable { + case matchingFailed(kern_return_t) +} + +public enum HostUsbOpenMode: Equatable, Sendable { + case userAuthorized + case seize + case capture +} + +public struct HostUsbOpenPlan: Equatable, Sendable { + public var mode: HostUsbOpenMode + public var authorize: Bool + public var requiresPrivilegedHelperForClaimedDevice: Bool + public var optionNames: [String] +} + +public enum HostUsbOpenError: Error, Equatable, Sendable { + case notFound(String) + case authorizationFailed(kern_return_t) + case openDeviceFailed +} + +public enum HostUsbDeviceFactory: Sendable { + public static func plan(mode: HostUsbOpenMode) -> HostUsbOpenPlan { + switch mode { + case .userAuthorized: + HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: []) + case .seize: + HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: false, optionNames: ["deviceSeize"]) + case .capture: + HostUsbOpenPlan(mode: mode, authorize: true, requiresPrivilegedHelperForClaimedDevice: true, optionNames: ["deviceCapture"]) + } + } + + public static func open(busID: String, mode: HostUsbOpenMode = .userAuthorized) throws -> HostUsbDevice { + let (candidate, service) = try findService(busID: busID) + defer { IOObjectRelease(service) } + let plan = plan(mode: mode) + if plan.authorize { + let kr = IOServiceAuthorize(service, UInt32(kIOServiceInteractionAllowed)) + guard kr == KERN_SUCCESS else { throw HostUsbOpenError.authorizationFailed(kr) } + } + guard let device = DoryIOUSBHostCreateDevice(service, options(for: mode), nil) else { + throw HostUsbOpenError.openDeviceFailed + } + let opened = collectPipes(deviceService: service, mode: mode) + let retained: [IOUSBHostObject] = [device] + opened.interfaces + let backend = IOUSBHostDeviceBackend(controlObject: device, pipes: opened.pipes, retainedObjects: retained) + return HostUsbDevice(descriptor: candidate.descriptor, backend: backend) + } + + private static func findService(busID: String) throws -> (HostUsbDeviceCandidate, io_service_t) { + var iterator: io_iterator_t = 0 + let kr = IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOUSBHostDevice"), &iterator) + guard kr == KERN_SUCCESS else { throw HostUsbDiscoveryError.matchingFailed(kr) } + defer { IOObjectRelease(iterator) } + + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + var props: Unmanaged? + guard IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let dictionary = props?.takeRetainedValue() as? [String: Any], + let candidate = HostUsbDiscovery.candidate(from: dictionary, service: service), + candidate.descriptor.busID == busID else { continue } + IOObjectRetain(service) + return (candidate, service) + } + throw HostUsbOpenError.notFound(busID) + } + + private static func collectPipes(deviceService: io_service_t, mode: HostUsbOpenMode) -> (interfaces: [IOUSBHostInterface], pipes: [UInt8: IOUSBHostPipe]) { + var iterator: io_iterator_t = 0 + guard IORegistryEntryCreateIterator(deviceService, kIOServicePlane, IOOptionBits(kIORegistryIterateRecursively), &iterator) == KERN_SUCCESS else { + return ([], [:]) + } + defer { IOObjectRelease(iterator) } + + var interfaces: [IOUSBHostInterface] = [] + var pipes: [UInt8: IOUSBHostPipe] = [:] + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + guard IOObjectConformsTo(service, "IOUSBHostInterface") != 0, + let hostInterface = DoryIOUSBHostCreateInterface(service, [], nil) else { + continue + } + var interfaceHasPipe = false + for endpoint in UInt8(1)...UInt8(15) { + for address in [endpoint, endpoint | 0x80] { + if pipes[address] == nil, let pipe = DoryIOUSBHostCopyPipe(hostInterface, UInt(address), nil) { + pipes[address] = pipe + interfaceHasPipe = true + } + } + } + if interfaceHasPipe { + interfaces.append(hostInterface) + } else { + DoryIOUSBHostDestroyObject(hostInterface, []) + } + } + return (interfaces, pipes) + } + + private static func options(for mode: HostUsbOpenMode) -> IOUSBHostObjectInitOptions { + switch mode { + case .userAuthorized: [] + case .seize: .deviceSeize + case .capture: .deviceCapture + } + } +} + +public enum HostUsbDiscovery: Sendable { + public static func list() throws -> [HostUsbDeviceCandidate] { + var iterator: io_iterator_t = 0 + let kr = IOServiceGetMatchingServices(kIOMainPortDefault, IOServiceMatching("IOUSBHostDevice"), &iterator) + guard kr == KERN_SUCCESS else { throw HostUsbDiscoveryError.matchingFailed(kr) } + defer { IOObjectRelease(iterator) } + + var result: [HostUsbDeviceCandidate] = [] + while true { + let service = IOIteratorNext(iterator) + guard service != 0 else { break } + defer { IOObjectRelease(service) } + var props: Unmanaged? + guard IORegistryEntryCreateCFProperties(service, &props, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let dictionary = props?.takeRetainedValue() as? [String: Any] else { continue } + if let candidate = candidate(from: dictionary, service: service) { + result.append(candidate) + } + } + return result.sorted { $0.descriptor.busID < $1.descriptor.busID } + } + + public static func candidate(from properties: [String: Any], service: io_registry_entry_t = 0) -> HostUsbDeviceCandidate? { + guard let vendorID = uint16(properties, keys: ["idVendor", "USB Vendor ID"]), + let productID = uint16(properties, keys: ["idProduct", "USB Product ID"]) else { return nil } + let locationID = uint32(properties, keys: ["locationID", "LocationID", "USB LocationID"]) + let deviceNumber = uint32(properties, keys: ["USB Address", "bDeviceAddress", "Device Address"]) ?? UInt32(service & 0xffff) + let busNumber = busNumber(fromLocationID: locationID) + let busID = properties["DoryBusID"] as? String ?? "\(busNumber)-\(deviceNumber)" + let path = registryPath(for: service, fallbackBusID: busID) + let descriptor = UsbipDeviceDescriptor( + path: path, + busID: busID, + busNumber: busNumber, + deviceNumber: deviceNumber, + speed: uint32(properties, keys: ["Device Speed", "speed", "USB Speed"]) ?? 0, + vendorID: vendorID, + productID: productID, + bcdDevice: uint16(properties, keys: ["bcdDevice", "USB Product Revision"]) ?? 0, + deviceClass: uint8(properties, keys: ["bDeviceClass", "USB Device Class"]) ?? 0, + deviceSubClass: uint8(properties, keys: ["bDeviceSubClass", "USB Device Subclass"]) ?? 0, + deviceProtocol: uint8(properties, keys: ["bDeviceProtocol", "USB Device Protocol"]) ?? 0, + configurationValue: uint8(properties, keys: ["bConfigurationValue", "CurrentConfiguration", "USB Current Configuration"]) ?? 1, + configurationCount: uint8(properties, keys: ["bNumConfigurations", "USB Configurations"]) ?? 1, + interfaceCount: uint8(properties, keys: ["bNumInterfaces", "USB Interfaces"]) ?? 0 + ) + return HostUsbDeviceCandidate( + descriptor: descriptor, + vendorName: string(properties, keys: ["USB Vendor Name", "kUSBVendorString", "iManufacturer"]), + productName: string(properties, keys: ["USB Product Name", "kUSBProductString", "iProduct"]), + serialNumber: string(properties, keys: ["USB Serial Number", "kUSBSerialNumberString", "iSerialNumber"]), + locationID: locationID + ) + } + + private static func registryPath(for service: io_registry_entry_t, fallbackBusID: String) -> String { + guard service != 0 else { return "/io/usb/\(fallbackBusID)" } + var path = [CChar](repeating: 0, count: 512) + if IORegistryEntryGetPath(service, kIOServicePlane, &path) == KERN_SUCCESS { + let bytes = path.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + return "/io/usb/\(fallbackBusID)" + } + + private static func busNumber(fromLocationID locationID: UInt32?) -> UInt32 { + guard let locationID else { return 0 } + return max(1, (locationID >> 24) & 0xff) + } + + private static func string(_ properties: [String: Any], keys: [String]) -> String? { + for key in keys { + if let value = properties[key] as? String, !value.isEmpty { return value } + } + return nil + } + + private static func uint8(_ properties: [String: Any], keys: [String]) -> UInt8? { + uint32(properties, keys: keys).flatMap { UInt8(exactly: $0) } + } + + private static func uint16(_ properties: [String: Any], keys: [String]) -> UInt16? { + uint32(properties, keys: keys).flatMap { UInt16(exactly: $0) } + } + + private static func uint32(_ properties: [String: Any], keys: [String]) -> UInt32? { + for key in keys { + guard let raw = properties[key] else { continue } + if let value = raw as? UInt32 { return value } + if let value = raw as? UInt16 { return UInt32(value) } + if let value = raw as? UInt8 { return UInt32(value) } + if let value = raw as? Int, value >= 0 { return UInt32(value) } + if let value = raw as? NSNumber { return value.uint32Value } + if let value = raw as? String { + if value.lowercased().hasPrefix("0x") { return UInt32(value.dropFirst(2), radix: 16) } + if let decimal = UInt32(value) { return decimal } + } + } + return nil + } +} + +public struct HostUsbControlSetup: Equatable, Sendable { + public var requestType: UInt8 + public var request: UInt8 + public var value: UInt16 + public var index: UInt16 + public var length: UInt16 + + public init(requestType: UInt8, request: UInt8, value: UInt16, index: UInt16, length: UInt16) { + self.requestType = requestType + self.request = request + self.value = value + self.index = index + self.length = length + } + + public init(usbipSetup bytes: [UInt8]) throws { + guard bytes.count >= 8 else { throw HostUsbTransferError.malformedSetup } + self.init( + requestType: bytes[0], + request: bytes[1], + value: UInt16(bytes[2]) | (UInt16(bytes[3]) << 8), + index: UInt16(bytes[4]) | (UInt16(bytes[5]) << 8), + length: UInt16(bytes[6]) | (UInt16(bytes[7]) << 8) + ) + } + + public func ioUSBDeviceRequest() -> IOUSBDeviceRequest { + IOUSBDeviceRequest( + bmRequestType: requestType, + bRequest: request, + wValue: value, + wIndex: index, + wLength: length + ) + } +} + +public struct HostUsbTransferResult: Equatable, Sendable { + public var status: Int32 + public var actualLength: UInt32 + public var data: [UInt8] + + public init(status: Int32, actualLength: UInt32, data: [UInt8] = []) { + self.status = status + self.actualLength = actualLength + self.data = data + } +} + +public enum HostUsbTransferError: Error, Equatable, Sendable { + case malformedSetup + case endpointNotFound(UInt8) + case failed(errno: Int32) +} + +public protocol HostUsbBackend: Sendable { + func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult + func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult + func abort(endpointAddress: UInt8?) throws +} + +public final class HostUsbDevice: UsbipExportedDevice, @unchecked Sendable { + public let descriptor: UsbipDeviceDescriptor + private let backend: any HostUsbBackend + private let timeout: TimeInterval + + public init(descriptor: UsbipDeviceDescriptor, backend: any HostUsbBackend, timeout: TimeInterval = 5) { + self.descriptor = descriptor + self.backend = backend + self.timeout = timeout + } + + public func submit(_ command: UsbipSubmitCommand) throws -> UsbipSubmitReply { + guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { + return reply(for: command, status: -EPIPE, actualLength: 0, data: []) + } + + do { + let result: HostUsbTransferResult + if command.header.endpoint == 0 { + let setup = try HostUsbControlSetup(usbipSetup: command.setup) + let payload = command.header.direction == .out ? command.transferBuffer : [] + result = try backend.control(setup, payload: payload, direction: command.header.direction, timeout: timeout) + } else { + result = try backend.transfer( + endpointAddress: Self.endpointAddress(number: command.header.endpoint, direction: command.header.direction), + payload: command.header.direction == .out ? command.transferBuffer : [], + expectedLength: command.transferBufferLength, + direction: command.header.direction, + timeout: command.header.endpoint == 0 ? timeout : (command.interval == 0 ? 0 : timeout) + ) + } + return reply(for: command, status: result.status, actualLength: result.actualLength, data: result.data) + } catch let error as HostUsbTransferError { + return reply(for: command, status: Self.usbipStatus(for: error), actualLength: 0, data: []) + } + } + + public func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply { + let status: Int32 + do { + try backend.abort(endpointAddress: nil) + status = 0 + } catch let error as HostUsbTransferError { + status = Self.usbipStatus(for: error) + } + let header = UsbipHeaderBasic(command: .retUnlink, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipUnlinkReply(header: header, status: status) + } + + nonisolated public static func endpointAddress(number: UInt32, direction: UsbipDirection) -> UInt8 { + UInt8(number & 0x0f) | (direction == .in ? 0x80 : 0x00) + } + + private func reply(for command: UsbipSubmitCommand, status: Int32, actualLength: UInt32, data: [UInt8]) -> UsbipSubmitReply { + let header = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipSubmitReply(header: header, status: status, actualLength: actualLength, transferBuffer: data) + } + + private nonisolated static func usbipStatus(for error: HostUsbTransferError) -> Int32 { + switch error { + case .malformedSetup: -EINVAL + case .endpointNotFound: -ENOENT + case .failed(let errno): -abs(errno) + } + } +} + +public final class IOUSBHostDeviceBackend: HostUsbBackend, @unchecked Sendable { + private let controlObject: IOUSBHostObject? + private let pipes: [UInt8: IOUSBHostPipe] + private let retainedObjects: [IOUSBHostObject] + private let controlHandler: (@Sendable (HostUsbControlSetup, [UInt8], UsbipDirection, TimeInterval) throws -> HostUsbTransferResult)? + private let lock = NSLock() + + public init( + controlObject: IOUSBHostObject? = nil, + pipes: [UInt8: IOUSBHostPipe], + retainedObjects: [IOUSBHostObject] = [], + controlHandler: (@Sendable (HostUsbControlSetup, [UInt8], UsbipDirection, TimeInterval) throws -> HostUsbTransferResult)? = nil + ) { + self.controlObject = controlObject + self.pipes = pipes + self.retainedObjects = retainedObjects + self.controlHandler = controlHandler + } + + deinit { + for object in retainedObjects { + DoryIOUSBHostDestroyObject(object, []) + } + } + + public func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + if let controlHandler { + return try controlHandler(setup, payload, direction, timeout) + } + guard let controlObject else { throw HostUsbTransferError.failed(errno: ENOTSUP) } + let length = direction == .in ? Int(setup.length) : payload.count + guard let data = NSMutableData(length: length) else { throw HostUsbTransferError.failed(errno: ENOMEM) } + if direction == .out { + data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) + } + var transferred = 0 + let request = setup.ioUSBDeviceRequest() + let ok = locked { + DoryIOUSBHostSendDeviceRequest(controlObject, request, data, &transferred, timeout, nil) + } + guard ok else { throw HostUsbTransferError.failed(errno: EIO) } + let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] + return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) + } + + public func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + guard let pipe = pipes[endpointAddress] else { throw HostUsbTransferError.endpointNotFound(endpointAddress) } + let length = direction == .in ? Int(expectedLength) : payload.count + guard let data = NSMutableData(length: length) else { throw HostUsbTransferError.failed(errno: ENOMEM) } + if direction == .out { + data.replaceBytes(in: NSRange(location: 0, length: min(payload.count, data.length)), withBytes: payload) + } + let completion = IOUSBCompletionBox() + let status = locked { + let semaphore = DispatchSemaphore(value: 0) + do { + try pipe.enqueueIORequest(with: data, completionTimeout: timeout) { status, count in + completion.set(status: status, count: count) + semaphore.signal() + } + } catch { + return kIOReturnError + } + semaphore.wait() + return completion.result.status + } + guard status == kIOReturnSuccess else { throw HostUsbTransferError.failed(errno: Self.errno(for: status)) } + let transferred = completion.result.count + let bytes = direction == .in ? Array(UnsafeBufferPointer(start: data.bytes.assumingMemoryBound(to: UInt8.self), count: min(transferred, data.length))) : [] + return HostUsbTransferResult(status: 0, actualLength: UInt32(transferred), data: bytes) + } + + public func abort(endpointAddress: UInt8?) throws { + if let endpointAddress { + guard let pipe = pipes[endpointAddress] else { + throw HostUsbTransferError.endpointNotFound(endpointAddress) + } + let ok = locked { + DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) + } + guard ok else { throw HostUsbTransferError.failed(errno: EIO) } + return + } + var ok = true + if let controlObject { + ok = locked { + DoryIOUSBHostAbortDeviceRequests(controlObject, IOUSBHostAbortOption.synchronous, nil) + } + } + guard ok else { throw HostUsbTransferError.failed(errno: EIO) } + for pipe in pipes.values { + let pipeOK = locked { + DoryIOUSBHostAbortPipe(pipe, IOUSBHostAbortOption.synchronous, nil) + } + guard pipeOK else { throw HostUsbTransferError.failed(errno: EIO) } + } + } + + private func locked(_ body: () throws -> T) rethrows -> T { + lock.lock() + defer { lock.unlock() } + return try body() + } + + nonisolated static func errno(for status: IOReturn) -> Int32 { + switch status { + case kIOReturnNotPermitted: EPERM + case kIOReturnNoDevice: ENODEV + case kIOReturnNotFound: ENOENT + case kIOReturnNoResources: ENOMEM + case kIOReturnTimeout: ETIMEDOUT + case kIOReturnAborted: ECANCELED + case kIOReturnNotOpen: ENODEV + case kIOReturnNotResponding: ETIMEDOUT + case kIOReturnExclusiveAccess: EBUSY + default: EIO + } + } +} + +private final class IOUSBCompletionBox: @unchecked Sendable { + private let lock = NSLock() + private var storedStatus: IOReturn = kIOReturnError + private var storedCount = 0 + + func set(status: IOReturn, count: Int) { + lock.lock() + storedStatus = status + storedCount = count + lock.unlock() + } + + var result: (status: IOReturn, count: Int) { + lock.lock() + defer { lock.unlock() } + return (storedStatus, storedCount) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift new file mode 100644 index 0000000..45e4c5b --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlHandler.swift @@ -0,0 +1,115 @@ +import Foundation + +public struct UsbAttachOutcome: Equatable, Sendable, Codable { + public var busID: String + public var port: Int + public var vsockPort: UInt32 + public var deviceID: UInt32 + public var speed: UInt32 +} + +public struct UsbAgentAttachRequest: Equatable, Sendable, Encodable { + public var busid: String + public var port: Int + public var vsock_port: UInt32 + public var device_id: UInt32 + public var speed: UInt32 +} + +public struct UsbAgentDetachRequest: Equatable, Sendable, Encodable { + public var busid: String + public var port: Int +} + +public enum UsbControlError: Error, Equatable, Sendable { + case alreadyAttached(String) + case notAttached(String) +} + +/// The engine-side logic behind `dory usb attach/detach`: claim the host device, register it with the +/// `UsbipManager` so the listener can serve it, and tell the guest agent to dial and vhci-attach. All +/// three collaborators are injected so the full sequence (including rollback when the guest notify +/// fails) is unit-testable without real hardware, a socket, or a running guest. +public final class UsbControlHandler: @unchecked Sendable { + private let manager: UsbipManager + private let openDevice: (String, HostUsbOpenMode) throws -> any UsbipExportedDevice + private let notifyAttach: (UsbAgentAttachRequest) async throws -> Void + private let notifyDetach: (UsbAgentDetachRequest) async throws -> Void + + private let lock = NSLock() + private var portByBusID: [String: Int] = [:] + private var usedPorts = Set() + + public init( + manager: UsbipManager, + openDevice: @escaping (String, HostUsbOpenMode) throws -> any UsbipExportedDevice, + notifyAttach: @escaping (UsbAgentAttachRequest) async throws -> Void, + notifyDetach: @escaping (UsbAgentDetachRequest) async throws -> Void + ) { + self.manager = manager + self.openDevice = openDevice + self.notifyAttach = notifyAttach + self.notifyDetach = notifyDetach + } + + public func attach(busID: String, mode: HostUsbOpenMode = .userAuthorized) async throws -> UsbAttachOutcome { + try lock.withLock { + guard portByBusID[busID] == nil else { throw UsbControlError.alreadyAttached(busID) } + } + let device = try openDevice(busID, mode) + manager.register(device) + let port = lock.withLock { allocatePortLocked(for: busID) } + let descriptor = device.descriptor + let request = UsbAgentAttachRequest( + busid: busID, + port: port, + vsock_port: manager.port, + device_id: (descriptor.busNumber << 16) | descriptor.deviceNumber, + speed: descriptor.speed + ) + do { + try await notifyAttach(request) + } catch { + // The guest could not attach — undo the host-side claim so the device returns to macOS. + manager.unregister(busID: busID) + lock.withLock { releasePortLocked(busID) } + throw error + } + return UsbAttachOutcome(busID: busID, port: port, vsockPort: request.vsock_port, deviceID: request.device_id, speed: request.speed) + } + + public func detach(busID: String) async throws { + let port = try lock.withLock { () -> Int in + guard let port = portByBusID[busID] else { throw UsbControlError.notAttached(busID) } + return port + } + try await notifyDetach(UsbAgentDetachRequest(busid: busID, port: port)) + manager.unregister(busID: busID) + lock.withLock { releasePortLocked(busID) } + } + + public var attachedBusIDs: [String] { + lock.withLock { portByBusID.keys.sorted() } + } + + private func allocatePortLocked(for busID: String) -> Int { + var port = 0 + while usedPorts.contains(port) { port += 1 } + usedPorts.insert(port) + portByBusID[busID] = port + return port + } + + private func releasePortLocked(_ busID: String) { + if let port = portByBusID.removeValue(forKey: busID) { + usedPorts.remove(port) + } + } +} + +private extension NSLock { + func withLock(_ body: () throws -> R) rethrows -> R { + lock(); defer { unlock() } + return try body() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift new file mode 100644 index 0000000..4891ae6 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbControlServer.swift @@ -0,0 +1,215 @@ +import Darwin +import Foundation + +public struct UsbControlRequest: Codable, Equatable, Sendable { + public var cmd: String // "attach" | "detach" + public var busid: String + public var mode: String? // "userAuthorized" | "seize" | "capture" (attach only) + + public init(cmd: String, busid: String, mode: String? = nil) { + self.cmd = cmd + self.busid = busid + self.mode = mode + } +} + +public struct UsbControlResponse: Codable, Equatable, Sendable { + public var ok: Bool + public var port: Int? + public var vsockPort: UInt32? + public var deviceID: UInt32? + public var speed: UInt32? + public var error: String? + + public static func success(_ outcome: UsbAttachOutcome) -> UsbControlResponse { + UsbControlResponse(ok: true, port: outcome.port, vsockPort: outcome.vsockPort, deviceID: outcome.deviceID, speed: outcome.speed, error: nil) + } + + public static func ok() -> UsbControlResponse { UsbControlResponse(ok: true, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: nil) } + public static func failure(_ message: String) -> UsbControlResponse { UsbControlResponse(ok: false, port: nil, vsockPort: nil, deviceID: nil, speed: nil, error: message) } +} + +/// Codec for the newline-delimited JSON control protocol. Pure and unit-tested; the socket layer only +/// moves bytes. +public enum UsbControlCodec { + public static func encodeRequest(_ request: UsbControlRequest) throws -> Data { + var data = try JSONEncoder().encode(request) + data.append(0x0a) + return data + } + + public static func decodeRequest(_ line: Data) throws -> UsbControlRequest { + try JSONDecoder().decode(UsbControlRequest.self, from: line) + } + + public static func encodeResponse(_ response: UsbControlResponse) throws -> Data { + var data = try JSONEncoder().encode(response) + data.append(0x0a) + return data + } + + public static func decodeResponse(_ line: Data) throws -> UsbControlResponse { + try JSONDecoder().decode(UsbControlResponse.self, from: line) + } + + public static func mode(from raw: String?) -> HostUsbOpenMode { + switch raw { + case "seize": return .seize + case "capture": return .capture + default: return .userAuthorized + } + } +} + +/// Serves the `dory usb attach/detach` control protocol on a unix socket in the engine process (which +/// owns the device claim, the UsbipManager, and the guest agent channel). One request per connection. +public final class UsbControlServer: @unchecked Sendable { + private let path: String + private let handler: UsbControlHandler + private let queue = DispatchQueue(label: "dory.usb.control") + private var listenFD: Int32 = -1 + + public init(path: String, handler: UsbControlHandler) { + self.path = path + self.handler = handler + } + + public func start() throws { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } + unlink(path) + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + Self.copyPath(path, into: &address) + let bound = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { bind(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard bound == 0 else { close(fd); throw UsbControlServerError.socket("bind \(path): errno \(errno)") } + guard listen(fd, 8) == 0 else { close(fd); throw UsbControlServerError.socket("listen: errno \(errno)") } + listenFD = fd + queue.async { [weak self] in self?.acceptLoop() } + } + + public func stop() { + if listenFD >= 0 { close(listenFD); listenFD = -1 } + unlink(path) + } + + private func acceptLoop() { + while true { + let client = accept(listenFD, nil, nil) + guard client >= 0 else { return } + handleClient(client) + } + } + + private func handleClient(_ fd: Int32) { + defer { close(fd) } + guard let line = Self.readLine(fd) else { return } + let response: UsbControlResponse + if let request = try? UsbControlCodec.decodeRequest(line) { + response = runHandler(request) + } else { + response = .failure("malformed control request") + } + if let data = try? UsbControlCodec.encodeResponse(response) { + _ = data.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + } + } + + private func runHandler(_ request: UsbControlRequest) -> UsbControlResponse { + let semaphore = DispatchSemaphore(value: 0) + let box = ResultBox() + let handler = self.handler + Task { + let response: UsbControlResponse + do { + switch request.cmd { + case "attach": + let outcome = try await handler.attach(busID: request.busid, mode: UsbControlCodec.mode(from: request.mode)) + response = .success(outcome) + case "detach": + try await handler.detach(busID: request.busid) + response = .ok() + default: + response = .failure("unknown command \(request.cmd)") + } + } catch { + response = .failure("\(error)") + } + box.value = response + semaphore.signal() + } + semaphore.wait() // one control request per connection; the accept loop serializes them + return box.value ?? .failure("no result") + } + + private final class ResultBox: @unchecked Sendable { + var value: UsbControlResponse? + } + + private static func readLine(_ fd: Int32) -> Data? { + var data = Data() + var byte: UInt8 = 0 + while data.count < 8192 { + let n = Darwin.read(fd, &byte, 1) + guard n == 1 else { return data.isEmpty ? nil : data } + if byte == 0x0a { return data } + data.append(byte) + } + return data + } + + private static func copyPath(_ path: String, into address: inout sockaddr_un) { + withUnsafeMutableBytes(of: &address.sun_path) { destination in + let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) + destination.copyBytes(from: bytes) + } + } +} + +public enum UsbControlServerError: Error, Equatable, Sendable { + case socket(String) +} + +/// The guest agent's usb.attach/usb.detach reply. We only need to know the call succeeded, so the +/// fields (`attached`/`detached`, `busid`, `port`) are optional and unused. +public struct UsbAgentReply: Decodable, Sendable { + public var busid: String? + public var port: Int? +} + +/// Client used by `dory-hv usb attach/detach`: connect to the engine's control socket, send one +/// request, read one response. +public enum UsbControlClient { + public static func send(_ request: UsbControlRequest, socketPath: String) throws -> UsbControlResponse { + let fd = socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { throw UsbControlServerError.socket("socket: errno \(errno)") } + defer { close(fd) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + UsbControlServer_copyPath(socketPath, into: &address) + let connected = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { connect(fd, $0, socklen_t(MemoryLayout.size)) } + } + guard connected == 0 else { throw UsbControlServerError.socket("connect \(socketPath): errno \(errno) (is the engine running?)") } + let payload = try UsbControlCodec.encodeRequest(request) + _ = payload.withUnsafeBytes { Darwin.write(fd, $0.baseAddress, $0.count) } + var response = Data() + var byte: UInt8 = 0 + while response.count < 8192 { + let n = Darwin.read(fd, &byte, 1) + guard n == 1 else { break } + if byte == 0x0a { break } + response.append(byte) + } + return try UsbControlCodec.decodeResponse(response) + } +} + +private func UsbControlServer_copyPath(_ path: String, into address: inout sockaddr_un) { + withUnsafeMutableBytes(of: &address.sun_path) { destination in + let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) + destination.copyBytes(from: bytes) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift new file mode 100644 index 0000000..53e3b9e --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipBridge.swift @@ -0,0 +1,105 @@ +import Darwin +import Foundation + +/// Frames the usbip command stream on the wire. The guest's vhci_hcd writes a fixed 48-byte usbip +/// command header, and CMD_SUBMIT with an OUT direction appends `transfer_buffer_length` more bytes. +enum UsbipCommandFraming { + static let fixedHeaderByteCount = UsbipSubmitCommand.headerByteCount + + static func outPayloadLength(_ header: [UInt8]) -> Int { + guard header.count >= fixedHeaderByteCount else { return 0 } + func be32(_ offset: Int) -> UInt32 { + (UInt32(header[offset]) << 24) | (UInt32(header[offset + 1]) << 16) + | (UInt32(header[offset + 2]) << 8) | UInt32(header[offset + 3]) + } + guard be32(0) == UsbipOperation.cmdSubmit.rawValue, + be32(12) == UsbipDirection.out.rawValue else { return 0 } + return Int(min(be32(24), UsbipSubmitCommand.maxTransferBytes)) + } +} + +/// Bridges one guest usbip vsock connection to one claimed host USB device. The guest agent dials +/// `VsockPorts.usbip` and performs the OP_REQ_IMPORT handshake; this bridge answers via `UsbipServer`, +/// then pumps USBIP_CMD_SUBMIT/UNLINK frames to the device and writes the replies back — until the +/// guest closes the connection (`isPeerClosed`), at which point `onClose` fires so the engine releases +/// the device. The serve loop runs on its own queue, never the vsock dispatch queue, because a host +/// device submit blocks on the transfer completing. +public final class UsbipBridge: @unchecked Sendable { + private let connection: VsockConnection + private let server: UsbipServer + private let onClose: () -> Void + private let queue: DispatchQueue + + /// Backed by a server that may export several claimed devices; the busID is read from the guest's + /// OP_REQ_IMPORT frame, so one listener can serve whichever device the guest asked for. + public init(connection: VsockConnection, server: UsbipServer, label: String = "shared", onClose: @escaping () -> Void = {}) { + self.connection = connection + self.server = server + self.onClose = onClose + self.queue = DispatchQueue(label: "dory.usbip.bridge.\(label)") + } + + /// Single-device convenience. + public convenience init(connection: VsockConnection, device: any UsbipExportedDevice, onClose: @escaping () -> Void = {}) { + self.init(connection: connection, server: UsbipServer(devices: [device]), label: device.descriptor.busID, onClose: onClose) + } + + public func start() { + queue.async { [weak self] in self?.serve() } + } + + /// Runs the serve loop synchronously; returns when the connection ends. Exposed for the loopback + /// integration test to drive the bridge without a real queue/thread. + public func serve() { + defer { + connection.close() + onClose() + } + guard let importFrame = readExact(UsbipImportRequest.byteCount), + let busID = (try? UsbipImportRequest(decoding: importFrame))?.busID, + let importReply = try? server.handleImport(importFrame) else { return } + write(importReply) + + while true { + guard let header = readExact(UsbipCommandFraming.fixedHeaderByteCount) else { return } + var frame = header + let extra = UsbipCommandFraming.outPayloadLength(header) + if extra > 0 { + guard let payload = readExact(extra) else { return } + frame += payload + } + guard let reply = try? server.handleURB(frame, busID: busID) else { return } + write(reply) + } + } + + private func write(_ bytes: [UInt8]) { + try? connection.write(bytes) + } + + /// Reads exactly `count` bytes, polling the non-blocking vsock connection with backoff. Returns + /// nil on EOF (the peer closed with fewer than `count` bytes remaining). "No data yet" is a wait, + /// not an error, so an idle device is never torn down — only a real peer close ends the loop. + private func readExact(_ count: Int) -> [UInt8]? { + guard count > 0 else { return [] } + var result = [UInt8]() + result.reserveCapacity(count) + var buffer = [UInt8](repeating: 0, count: count) + var pollInterval: useconds_t = 1_000 + let maxPollInterval: useconds_t = 16_000 + while result.count < count { + let read = (try? buffer.withUnsafeMutableBytes { + try connection.read(into: UnsafeMutableRawBufferPointer(rebasing: $0[0..<(count - result.count)])) + }) ?? 0 + if read == 0 { + if connection.isPeerClosed { return nil } + usleep(pollInterval) + pollInterval = min(pollInterval * 2, maxPollInterval) + continue + } + result.append(contentsOf: buffer.prefix(read)) + pollInterval = 1_000 + } + return result + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift new file mode 100644 index 0000000..c1c0b91 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipManager.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Owns the engine's usbip listener and the set of currently-claimed host devices. `dory usb attach` +/// claims a device (via the control plane) and `register`s it here; the guest agent then dials +/// `VsockPorts.usbip`, and the accepted connection is served by a `UsbipBridge` backed by whatever +/// devices are registered — the guest's OP_REQ_IMPORT busID selects which one. Detach `unregister`s it. +public final class UsbipManager: @unchecked Sendable { + private let lock = NSLock() + private var devices: [String: any UsbipExportedDevice] = [:] + private let vsockPort: UInt32 + + public init(vsockPort: UInt32 = VsockPorts.usbip) { + self.vsockPort = vsockPort + } + + public var port: UInt32 { vsockPort } + + /// Registers the listener on the engine's vsock so guest usbip dials are served on their own + /// bridge queue (never the vsock dispatch queue). + public func attachListener(to vsock: VirtioVsock) { + vsock.listen(port: vsockPort) { [weak self] connection in + guard let self else { connection.close(); return } + let exported = self.exportedDevices() + guard !exported.isEmpty else { connection.close(); return } + UsbipBridge(connection: connection, server: UsbipServer(devices: exported)).start() + } + } + + public func register(_ device: any UsbipExportedDevice) { + lock.lock(); defer { lock.unlock() } + devices[device.descriptor.busID] = device + } + + @discardableResult + public func unregister(busID: String) -> (any UsbipExportedDevice)? { + lock.lock(); defer { lock.unlock() } + return devices.removeValue(forKey: busID) + } + + public func exportedDevice(busID: String) -> (any UsbipExportedDevice)? { + lock.lock(); defer { lock.unlock() } + return devices[busID] + } + + public func exportedDevices() -> [any UsbipExportedDevice] { + lock.lock(); defer { lock.unlock() } + return Array(devices.values) + } + + public var claimedBusIDs: [String] { + lock.lock(); defer { lock.unlock() } + return devices.keys.sorted() + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift new file mode 100644 index 0000000..bbe89ad --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift @@ -0,0 +1,402 @@ +import Foundation + +public enum UsbipProtocolError: Error, Equatable { + case shortFrame + case invalidString + case transferBufferTooLarge(UInt32) +} + +public enum UsbipOperation: UInt32, Sendable { + case cmdSubmit = 0x0000_0001 + case cmdUnlink = 0x0000_0002 + case retSubmit = 0x0000_0003 + case retUnlink = 0x0000_0004 +} + +public enum UsbipDirection: UInt32, Sendable { + case out = 0 + case `in` = 1 +} + +public enum UsbipOpCode: UInt16, Sendable { + case reqImport = 0x8003 + case repImport = 0x0003 +} + +public struct UsbipOperationHeader: Equatable, Sendable { + public static let byteCount = 8 + public static let version: UInt16 = 0x0111 + + public var version: UInt16 + public var code: UInt16 + public var status: UInt32 + + public init(version: UInt16 = Self.version, code: UInt16, status: UInt32 = 0) { + self.version = version + self.code = code + self.status = status + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + self.init(version: bytes.beUInt16(at: 0), code: bytes.beUInt16(at: 2), status: bytes.beUInt32(at: 4)) + } + + public func encoded() -> [UInt8] { + var bytes = [UInt8]() + bytes.appendBE(version) + bytes.appendBE(code) + bytes.appendBE(status) + return bytes + } +} + +public struct UsbipDeviceDescriptor: Codable, Equatable, Sendable { + public static let byteCount = 312 + + public var path: String + public var busID: String + public var busNumber: UInt32 + public var deviceNumber: UInt32 + public var speed: UInt32 + public var vendorID: UInt16 + public var productID: UInt16 + public var bcdDevice: UInt16 + public var deviceClass: UInt8 + public var deviceSubClass: UInt8 + public var deviceProtocol: UInt8 + public var configurationValue: UInt8 + public var configurationCount: UInt8 + public var interfaceCount: UInt8 + + public init( + path: String, + busID: String, + busNumber: UInt32, + deviceNumber: UInt32, + speed: UInt32, + vendorID: UInt16, + productID: UInt16, + bcdDevice: UInt16, + deviceClass: UInt8, + deviceSubClass: UInt8, + deviceProtocol: UInt8, + configurationValue: UInt8, + configurationCount: UInt8, + interfaceCount: UInt8 + ) { + self.path = path + self.busID = busID + self.busNumber = busNumber + self.deviceNumber = deviceNumber + self.speed = speed + self.vendorID = vendorID + self.productID = productID + self.bcdDevice = bcdDevice + self.deviceClass = deviceClass + self.deviceSubClass = deviceSubClass + self.deviceProtocol = deviceProtocol + self.configurationValue = configurationValue + self.configurationCount = configurationCount + self.interfaceCount = interfaceCount + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + self.init( + path: try bytes.cString(at: 0, length: 256), + busID: try bytes.cString(at: 256, length: 32), + busNumber: bytes.beUInt32(at: 288), + deviceNumber: bytes.beUInt32(at: 292), + speed: bytes.beUInt32(at: 296), + vendorID: bytes.beUInt16(at: 300), + productID: bytes.beUInt16(at: 302), + bcdDevice: bytes.beUInt16(at: 304), + deviceClass: bytes[306], + deviceSubClass: bytes[307], + deviceProtocol: bytes[308], + configurationValue: bytes[309], + configurationCount: bytes[310], + interfaceCount: bytes[311] + ) + } + + public func encoded() -> [UInt8] { + var bytes = [UInt8]() + bytes.appendCString(path, width: 256) + bytes.appendCString(busID, width: 32) + bytes.appendBE(busNumber) + bytes.appendBE(deviceNumber) + bytes.appendBE(speed) + bytes.appendBE(vendorID) + bytes.appendBE(productID) + bytes.appendBE(bcdDevice) + bytes.append(deviceClass) + bytes.append(deviceSubClass) + bytes.append(deviceProtocol) + bytes.append(configurationValue) + bytes.append(configurationCount) + bytes.append(interfaceCount) + return bytes + } +} + +public struct UsbipImportRequest: Equatable, Sendable { + public static let byteCount = 40 + + public var busID: String + + public init(busID: String) { + self.busID = busID + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + let header = try UsbipOperationHeader(decoding: bytes) + guard header.code == UsbipOpCode.reqImport.rawValue else { throw UsbipProtocolError.shortFrame } + self.init(busID: try bytes.cString(at: 8, length: 32)) + } + + public func encoded() -> [UInt8] { + var bytes = UsbipOperationHeader(code: UsbipOpCode.reqImport.rawValue).encoded() + bytes.appendCString(busID, width: 32) + return bytes + } +} + +public struct UsbipImportReply: Equatable, Sendable { + public var status: UInt32 + public var device: UsbipDeviceDescriptor? + + public init(status: UInt32, device: UsbipDeviceDescriptor?) { + self.status = status + self.device = device + } + + public func encoded() -> [UInt8] { + var bytes = UsbipOperationHeader(code: UsbipOpCode.repImport.rawValue, status: status).encoded() + if status == 0, let device { + bytes.append(contentsOf: device.encoded()) + } + return bytes + } +} + +public struct UsbipHeaderBasic: Equatable, Sendable { + public static let byteCount = 20 + + public var command: UsbipOperation + public var sequenceNumber: UInt32 + public var deviceID: UInt32 + public var direction: UsbipDirection + public var endpoint: UInt32 + + public init(command: UsbipOperation, sequenceNumber: UInt32, deviceID: UInt32, direction: UsbipDirection, endpoint: UInt32) { + self.command = command + self.sequenceNumber = sequenceNumber + self.deviceID = deviceID + self.direction = direction + self.endpoint = endpoint + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + self.init( + command: UsbipOperation(rawValue: bytes.beUInt32(at: 0)) ?? .cmdSubmit, + sequenceNumber: bytes.beUInt32(at: 4), + deviceID: bytes.beUInt32(at: 8), + direction: UsbipDirection(rawValue: bytes.beUInt32(at: 12)) ?? .out, + endpoint: bytes.beUInt32(at: 16) + ) + } + + public func encoded() -> [UInt8] { + var bytes = [UInt8]() + bytes.appendBE(command.rawValue) + bytes.appendBE(sequenceNumber) + bytes.appendBE(deviceID) + bytes.appendBE(direction.rawValue) + bytes.appendBE(endpoint) + return bytes + } +} + +public struct UsbipSubmitCommand: Equatable, Sendable { + public static let headerByteCount = 48 + public static let maxTransferBytes: UInt32 = 4 * 1024 * 1024 + + public var header: UsbipHeaderBasic + public var transferFlags: UInt32 + public var transferBufferLength: UInt32 + public var startFrame: UInt32 + public var numberOfPackets: UInt32 + public var interval: UInt32 + public var setup: [UInt8] + public var transferBuffer: [UInt8] + + public init(header: UsbipHeaderBasic, transferFlags: UInt32, transferBufferLength: UInt32, startFrame: UInt32, numberOfPackets: UInt32, interval: UInt32, setup: [UInt8], transferBuffer: [UInt8]) { + self.header = header + self.transferFlags = transferFlags + self.transferBufferLength = transferBufferLength + self.startFrame = startFrame + self.numberOfPackets = numberOfPackets + self.interval = interval + self.setup = Array(setup.prefix(8)) + Array(repeating: 0, count: max(0, 8 - setup.count)) + self.transferBuffer = transferBuffer + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.headerByteCount else { throw UsbipProtocolError.shortFrame } + let header = try UsbipHeaderBasic(decoding: bytes) + let rawTransferLength = bytes.beUInt32(at: 24) + guard rawTransferLength <= Self.maxTransferBytes else { + throw UsbipProtocolError.transferBufferTooLarge(rawTransferLength) + } + let transferLength = Int(rawTransferLength) + let payloadLength = header.direction == .out ? transferLength : 0 + guard bytes.count >= Self.headerByteCount + payloadLength else { throw UsbipProtocolError.shortFrame } + self.init( + header: header, + transferFlags: bytes.beUInt32(at: 20), + transferBufferLength: UInt32(transferLength), + startFrame: bytes.beUInt32(at: 28), + numberOfPackets: bytes.beUInt32(at: 32), + interval: bytes.beUInt32(at: 36), + setup: Array(bytes[40..<48]), + transferBuffer: Array(bytes[48..<(48 + payloadLength)]) + ) + } + + public func encoded() -> [UInt8] { + var bytes = header.encoded() + bytes.appendBE(transferFlags) + bytes.appendBE(transferBufferLength) + bytes.appendBE(startFrame) + bytes.appendBE(numberOfPackets) + bytes.appendBE(interval) + bytes.append(contentsOf: setup.prefix(8)) + if header.direction == .out { + bytes.append(contentsOf: transferBuffer) + } + return bytes + } +} + +public struct UsbipSubmitReply: Equatable, Sendable { + public static let headerByteCount = 48 + + public var header: UsbipHeaderBasic + public var status: Int32 + public var actualLength: UInt32 + public var startFrame: UInt32 + public var numberOfPackets: UInt32 + public var errorCount: UInt32 + public var transferBuffer: [UInt8] + + public init(header: UsbipHeaderBasic, status: Int32, actualLength: UInt32, startFrame: UInt32 = 0, numberOfPackets: UInt32 = 0xffff_ffff, errorCount: UInt32 = 0, transferBuffer: [UInt8] = []) { + self.header = header + self.status = status + self.actualLength = actualLength + self.startFrame = startFrame + self.numberOfPackets = numberOfPackets + self.errorCount = errorCount + self.transferBuffer = transferBuffer + } + + public func encoded() -> [UInt8] { + var bytes = header.encoded() + bytes.appendBE(UInt32(bitPattern: status)) + bytes.appendBE(actualLength) + bytes.appendBE(startFrame) + bytes.appendBE(numberOfPackets) + bytes.appendBE(errorCount) + bytes.append(contentsOf: [UInt8](repeating: 0, count: 8)) + // Server response headers keep direction zero; payload presence is driven by the transfer result. + if !transferBuffer.isEmpty { + bytes.append(contentsOf: transferBuffer.prefix(Int(actualLength))) + } + return bytes + } +} + +public struct UsbipUnlinkCommand: Equatable, Sendable { + public static let byteCount = 48 + + public var header: UsbipHeaderBasic + public var unlinkSequenceNumber: UInt32 + + public init(header: UsbipHeaderBasic, unlinkSequenceNumber: UInt32) { + self.header = header + self.unlinkSequenceNumber = unlinkSequenceNumber + } + + public init(decoding bytes: [UInt8]) throws { + guard bytes.count >= Self.byteCount else { throw UsbipProtocolError.shortFrame } + self.init(header: try UsbipHeaderBasic(decoding: bytes), unlinkSequenceNumber: bytes.beUInt32(at: 20)) + } + + public func encoded() -> [UInt8] { + var bytes = header.encoded() + bytes.appendBE(unlinkSequenceNumber) + bytes.append(contentsOf: [UInt8](repeating: 0, count: 24)) + return bytes + } +} + +public struct UsbipUnlinkReply: Equatable, Sendable { + public static let byteCount = 48 + + public var header: UsbipHeaderBasic + public var status: Int32 + + public init(header: UsbipHeaderBasic, status: Int32) { + self.header = header + self.status = status + } + + public func encoded() -> [UInt8] { + var bytes = header.encoded() + bytes.appendBE(UInt32(bitPattern: status)) + bytes.append(contentsOf: [UInt8](repeating: 0, count: 24)) + return bytes + } +} + +private extension Array where Element == UInt8 { + mutating func appendBE(_ value: UInt16) { + Swift.withUnsafeBytes(of: value.bigEndian) { append(contentsOf: $0) } + } + + mutating func appendBE(_ value: UInt32) { + Swift.withUnsafeBytes(of: value.bigEndian) { append(contentsOf: $0) } + } + + mutating func appendCString(_ value: String, width: Int) { + let bytes = Array(value.utf8.prefix(Swift.max(0, width - 1))) + append(contentsOf: bytes) + append(0) + append(contentsOf: [UInt8](repeating: 0, count: Swift.max(0, width - bytes.count - 1))) + } + + func beUInt16(at offset: Int) -> UInt16 { + UInt16(self[offset]) << 8 | UInt16(self[offset + 1]) + } + + func beUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) << 24 + | UInt32(self[offset + 1]) << 16 + | UInt32(self[offset + 2]) << 8 + | UInt32(self[offset + 3]) + } + + func cString(at offset: Int, length: Int) throws -> String { + let end = offset + length + guard count >= end else { throw UsbipProtocolError.shortFrame } + let slice = self[offset.. UsbipSubmitReply + func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply +} + +public enum UsbipServerError: Error, Equatable { + case unknownDevice(String) + case unsupportedIsochronous +} + +public final class UsbipServer: @unchecked Sendable { + private let devicesByBusID: [String: any UsbipExportedDevice] + + public init(devices: [any UsbipExportedDevice]) { + self.devicesByBusID = Dictionary(uniqueKeysWithValues: devices.map { ($0.descriptor.busID, $0) }) + } + + public func handleImport(_ bytes: [UInt8]) throws -> [UInt8] { + let request = try UsbipImportRequest(decoding: bytes) + guard let device = devicesByBusID[request.busID] else { + return UsbipImportReply(status: 1, device: nil).encoded() + } + return UsbipImportReply(status: 0, device: device.descriptor).encoded() + } + + public func handleURB(_ bytes: [UInt8], busID: String) throws -> [UInt8] { + guard let device = devicesByBusID[busID] else { + throw UsbipServerError.unknownDevice(busID) + } + let basic = try UsbipHeaderBasic(decoding: bytes) + switch basic.command { + case .cmdSubmit: + let command = try UsbipSubmitCommand(decoding: bytes) + guard command.numberOfPackets == 0 || command.numberOfPackets == 0xffff_ffff else { + let replyHeader = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipSubmitReply(header: replyHeader, status: -EPIPE, actualLength: 0, numberOfPackets: command.numberOfPackets).encoded() + } + return try device.submit(command).encoded() + case .cmdUnlink: + return try device.unlink(try UsbipUnlinkCommand(decoding: bytes)).encoded() + case .retSubmit, .retUnlink: + throw UsbipProtocolError.shortFrame + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VCPU.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VCPU.swift new file mode 100644 index 0000000..ed4fe7c --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VCPU.swift @@ -0,0 +1,86 @@ +import Hypervisor + +/// One guest CPU. Hypervisor.framework requires that a vcpu is created, run, and destroyed on the +/// SAME thread, so instances are confined to their owning thread by construction and never shared. +public final class VCPU { + public let handle: hv_vcpu_t + private let exitInfo: UnsafeMutablePointer + + public init() throws { + var vcpu: hv_vcpu_t = 0 + var exitPointer: UnsafeMutablePointer? + try hvCheck(hv_vcpu_create(&vcpu, &exitPointer, nil), "hv_vcpu_create") + guard let exitPointer else { + throw VMError.bootFailure("hv_vcpu_create returned no exit buffer") + } + self.handle = vcpu + self.exitInfo = exitPointer + } + + deinit { + hv_vcpu_destroy(handle) + } + + public func read(_ register: hv_reg_t) throws -> UInt64 { + var value: UInt64 = 0 + try hvCheck(hv_vcpu_get_reg(handle, register, &value), "hv_vcpu_get_reg") + return value + } + + public func write(_ register: hv_reg_t, _ value: UInt64) throws { + try hvCheck(hv_vcpu_set_reg(handle, register, value), "hv_vcpu_set_reg") + } + + public func readSystem(_ register: hv_sys_reg_t) throws -> UInt64 { + var value: UInt64 = 0 + try hvCheck(hv_vcpu_get_sys_reg(handle, register, &value), "hv_vcpu_get_sys_reg") + return value + } + + public func writeSystem(_ register: hv_sys_reg_t, _ value: UInt64) throws { + try hvCheck(hv_vcpu_set_sys_reg(handle, register, value), "hv_vcpu_set_sys_reg") + } + + public func setVTimerMask(_ masked: Bool) throws { + try hvCheck(hv_vcpu_set_vtimer_mask(handle, masked), "hv_vcpu_set_vtimer_mask") + } + + @discardableResult + public func run() throws -> ExitEvent { + try hvCheck(hv_vcpu_run(handle), "hv_vcpu_run") + let exit = exitInfo.pointee + switch exit.reason { + case HV_EXIT_REASON_CANCELED: + return .canceled + case HV_EXIT_REASON_VTIMER_ACTIVATED: + return .vtimerActivated + case HV_EXIT_REASON_EXCEPTION: + return .exception( + syndrome: exit.exception.syndrome, + virtualAddress: exit.exception.virtual_address, + physicalAddress: exit.exception.physical_address + ) + default: + return .unknown(rawReason: exit.reason.rawValue) + } + } + + public enum ExitEvent { + case canceled + case vtimerActivated + case exception(syndrome: UInt64, virtualAddress: UInt64, physicalAddress: UInt64) + case unknown(rawReason: UInt32) + } +} + +public enum ExceptionClass: UInt64 { + case hvc64 = 0x16 + case smc64 = 0x17 + case systemRegisterTrap = 0x18 + case instructionAbortLowerEL = 0x20 + case dataAbortLowerEL = 0x24 + + public init?(syndrome: UInt64) { + self.init(rawValue: syndrome >> 26) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift new file mode 100644 index 0000000..0fcfbf8 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBalloon.swift @@ -0,0 +1,71 @@ +import Darwin +import Foundation + +/// virtio-balloon with free page reporting (VIRTIO_BALLOON_F_REPORTING): the guest batches ranges +/// of free pages onto the reporting queue and this device hands them straight back to macOS with +/// madvise. This is the mechanism Virtualization.framework lacks, and the reason dory-hv exists: +/// the host footprint tracks what the guest is actually using instead of its high-water mark. +public final class VirtioBalloon: VirtioDeviceBackend { + public let deviceID: UInt32 = 5 + public let queueCount = 3 // inflate, deflate, reporting + public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_BALLOON_F_REPORTING + + private let memory: GuestMemory + private let log: (String) -> Void + public private(set) var reclaimedBytes: UInt64 = 0 + public private(set) var reportEvents: UInt64 = 0 + private var advice: Int32? + + private static let hostPageSize: UInt64 = 16384 + private static let madvZero: Int32 = 11 // MADV_ZERO: release physical pages, zero-fill refault + + public init(memory: GuestMemory, log: @escaping (String) -> Void = { _ in }) { + self.memory = memory + self.log = log + } + + public var configSpace: [UInt8] { + // num_pages = 0 (no inflation requested), actual = 0. + [UInt8](repeating: 0, count: 8) + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + let virtqueue = transport.queues[queue] + var interrupt = false + while let chain = (try? virtqueue.pop()) ?? nil { + if queue == 2 { + reclaim(chain: chain) + } + // Inflate and deflate chains complete as no-ops: the ceiling is enforced by RAM size + // and reporting handles elasticity, so the classic balloon stays parked at zero. + let wants = (try? virtqueue.push(chain, written: 0)) ?? false + interrupt = interrupt || wants + } + if interrupt { + transport.notifyUsed() + } + } + + /// Every segment of a reporting chain IS a run of free guest pages. Stage-2 mappings pin the + /// backing pages, so each range is unmapped from the guest and only then marked reusable; + /// GuestMemory.releaseRange does both. The guest tolerates zero-filled refaults on reported + /// pages by contract, and the RAM-fault path in the run loop remaps blocks on first touch. + private func reclaim(chain: VirtqueueChain) { + let hostBase = UInt64(UInt(bitPattern: memory.hostBase)) + for segment in chain.segments { + let start = UInt64(UInt(bitPattern: segment.pointer)) + let end = start + UInt64(segment.length) + let alignedStart = (start + Self.hostPageSize - 1) & ~(Self.hostPageSize - 1) + let alignedEnd = end & ~(Self.hostPageSize - 1) + guard alignedEnd > alignedStart else { continue } + let guestAddress = memory.guestBase + (alignedStart - hostBase) + if memory.releaseRange(guestAddress: guestAddress, length: alignedEnd - alignedStart) { + reclaimedBytes &+= alignedEnd - alignedStart + } + } + reportEvents &+= 1 + if reportEvents <= 30 || reportEvents % 64 == 0 { + log("balloon: report #\(reportEvents), \(chain.segments.count) ranges, total \(reclaimedBytes >> 20) MiB reclaimed") + } + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift new file mode 100644 index 0000000..25290bd --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioBlk.swift @@ -0,0 +1,138 @@ +import Darwin +import Foundation + +/// virtio-blk backed by a raw disk image. Requests are served synchronously on the kicking vCPU's +/// thread with zero-copy pread/pwrite straight into guest RAM. +public final class VirtioBlk: VirtioDeviceBackend { + public let deviceID: UInt32 = 2 + public let queueCount = 1 + public var deviceFeatures: UInt64 { 1 << 9 } // VIRTIO_BLK_F_FLUSH + + private let fileDescriptor: Int32 + private let capacitySectors: UInt64 + private let identity: String + private let readOnly: Bool + + private enum RequestType: UInt32 { + case read = 0 + case write = 1 + case flush = 4 + case getID = 8 + } + + private enum RequestStatus: UInt8 { + case ok = 0 + case ioError = 1 + case unsupported = 2 + } + + public init(path: String, identity: String, readOnly: Bool = false) throws { + let descriptor = open(path, readOnly ? O_RDONLY : O_RDWR) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration("cannot open disk image \(path): errno \(errno)") + } + var info = stat() + guard fstat(descriptor, &info) == 0 else { + close(descriptor) + throw VMError.invalidConfiguration("cannot stat disk image \(path)") + } + self.fileDescriptor = descriptor + self.capacitySectors = UInt64(info.st_size) / 512 + self.identity = identity + self.readOnly = readOnly + } + + deinit { + close(fileDescriptor) + } + + public var configSpace: [UInt8] { + var config = [UInt8]() + withUnsafeBytes(of: capacitySectors.littleEndian) { config.append(contentsOf: $0) } + return config + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 0 else { return } + let virtqueue = transport.queues[0] + var interrupt = false + while let chain = (try? virtqueue.pop()) ?? nil { + let written = process(chain: chain) + let wants = (try? virtqueue.push(chain, written: written)) ?? false + interrupt = interrupt || wants + } + if interrupt { + transport.notifyUsed() + } + } + + private func process(chain: VirtqueueChain) -> Int { + let segments = chain.segments + guard segments.count >= 2, + !segments[0].isDeviceWritable, segments[0].length >= 16, + let statusSegment = segments.last, statusSegment.isDeviceWritable, statusSegment.length >= 1 else { + return 0 + } + + let header = segments[0].pointer + let rawType = header.loadUnaligned(fromByteOffset: 0, as: UInt32.self) + let sector = header.loadUnaligned(fromByteOffset: 8, as: UInt64.self) + let dataSegments = segments[1..<(segments.count - 1)] + + var written = 0 + let status: RequestStatus + switch RequestType(rawValue: UInt32(littleEndian: rawType)) { + case .read: + status = transfer(dataSegments, from: sector, into: &written, reading: true) + case .write: + status = readOnly ? .ioError : transfer(dataSegments, from: sector, into: &written, reading: false) + case .flush: + status = fcntl(fileDescriptor, F_FULLFSYNC) == 0 ? .ok : .ioError + case .getID: + let id = [UInt8](identity.utf8.prefix(20)) + for segment in dataSegments where segment.isDeviceWritable { + let count = min(segment.length, id.count) + id.withUnsafeBytes { segment.pointer.copyMemory(from: $0.baseAddress!, byteCount: count) } + written += count + break + } + status = .ok + case nil: + status = .unsupported + } + + statusSegment.pointer.storeBytes(of: status.rawValue, as: UInt8.self) + return written + 1 + } + + private func transfer( + _ segments: ArraySlice, + from sector: UInt64, + into written: inout Int, + reading: Bool + ) -> RequestStatus { + var offset = off_t(UInt64(littleEndian: sector) * 512) + for segment in segments { + if reading { + guard segment.isDeviceWritable else { return .ioError } + var done = 0 + while done < segment.length { + let bytes = pread(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) + guard bytes > 0 else { return .ioError } + done += bytes + } + written += segment.length + } else { + guard !segment.isDeviceWritable else { return .ioError } + var done = 0 + while done < segment.length { + let bytes = pwrite(fileDescriptor, segment.pointer + done, segment.length - done, offset + off_t(done)) + guard bytes > 0 else { return .ioError } + done += bytes + } + } + offset += off_t(segment.length) + } + return .ok + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift new file mode 100644 index 0000000..bd00051 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift @@ -0,0 +1,133 @@ +import Foundation + +public enum VirtioFSError: Error, Equatable { + case invalidTag(String) + case invalidDaxWindow +} + +public struct VirtioFSDaxConfiguration: Equatable, Sendable { + public var guestBase: UInt64 + public var length: UInt64 + + public init(guestBase: UInt64, length: UInt64 = DaxWindow.defaultSize) { + self.guestBase = guestBase + self.length = length + } +} + +public final class VirtioFS: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { + public static let tagByteCount = 36 + public static let notificationFeature: UInt64 = 1 << 0 + + public let deviceID: UInt32 = 26 + public let queueCount = 2 // 0 = hiprio, 1 = request + public let tag: String + public let hostFS: HostFS + public let daxConfiguration: VirtioFSDaxConfiguration? + private let server: FuseServer + public var deviceFeatures: UInt64 { 0 } + + // Request processing must never run on the vCPU thread: a blocking pread inside the MMIO + // queue-notify exit freezes the guest until every pending chain completes serially, collapsing + // a deep request queue to effective depth 1. Instead each kick spawns a "drainer" on a + // concurrent pool (unless the pool is already at capacity). A drainer loops popping chains and + // running the FUSE handler, so a steady backlog keeps up to maxDrainers preads in flight while a + // single request costs one dispatch. Completions publish out of order. + private let workers = DispatchQueue(label: "dory-hv.virtiofs.worker", attributes: .concurrent) + private let maxDrainers = max(4, min(16, ProcessInfo.processInfo.activeProcessorCount)) + private let drainLock = NSLock() + private var activeDrainers = 0 + private var kickGeneration = 0 + + public init(tag: String, hostFS: HostFS, daxConfiguration: VirtioFSDaxConfiguration? = nil) throws { + let bytes = Array(tag.utf8) + guard !bytes.isEmpty, bytes.count < Self.tagByteCount else { + throw VirtioFSError.invalidTag(tag) + } + if let daxConfiguration { + guard daxConfiguration.guestBase.isMultiple(of: DaxWindow.pageSize), + daxConfiguration.length > 0, + daxConfiguration.length.isMultiple(of: DaxWindow.pageSize) else { + throw VirtioFSError.invalidDaxWindow + } + } + self.tag = tag + self.hostFS = hostFS + self.daxConfiguration = daxConfiguration + let daxWindow = try daxConfiguration.map { + try DaxWindow(guestBase: $0.guestBase, length: $0.length, backend: FileBackedDaxMappingBackend()) + } + self.server = FuseServer(hostFS: hostFS, daxWindow: daxWindow) + } + + public var sharedMemoryRegions: [VirtioSharedMemoryRegion] { + guard let daxConfiguration else { return [] } + return [VirtioSharedMemoryRegion(id: 0, guestBase: daxConfiguration.guestBase, length: daxConfiguration.length)] + } + + public var configSpace: [UInt8] { + var data = [UInt8](repeating: 0, count: Self.tagByteCount) + let tagBytes = Array(tag.utf8) + data.replaceSubrange(0.. Bool { + let request = chain.readBytes() + var written = 0 + let writable = chain.writableSegments + if !writable.isEmpty, + let header = try? FuseProtocol.decodeInHeader(request), + header.length >= UInt32(FuseInHeader.byteCount), Int(header.length) <= request.count, + FuseOpcode(rawValue: header.opcode) == .read { + // Zero-copy fast path: preadv the payload straight into the guest's read buffers. + let payload = Array(request[FuseInHeader.byteCount..`; a value mounts at + /// that absolute guest path so a host directory can appear at its identical macOS path (e.g. + /// `$HOME` at `$HOME`), which is what makes `-v /Users/…:/…` bind mounts resolve transparently. + public var guestMountPoint: String? + /// Entry names hidden from the guest at any depth (see `HostFS.hiddenNames`). The `:safe` share + /// option applies `sensitiveNames` so a whole-home share never exposes credential stores or + /// shell rc files to containers. + public var hiddenNames: Set + + /// Credential stores, cloud/CLI secrets, and shell rc files that must never be exposed by a + /// broad host share. Hidden by name at any depth. This is a defense-in-depth default for the + /// convenience home share; the stronger guarantee is per-bind-mount on-demand sharing. + public static let sensitiveNames: Set = [ + ".ssh", ".aws", ".gcloud", ".azure", ".kube", ".docker", ".gnupg", ".config", + ".netrc", ".npmrc", ".pypirc", ".pgpass", ".git-credentials", ".terraform.d", + ".zshrc", ".zshenv", ".zprofile", ".zlogin", ".bashrc", ".bash_profile", ".profile", + "Library", + ] + + public init(tag: String, path: String, readOnly: Bool = false, dax: Bool = false, guestMountPoint: String? = nil, hiddenNames: Set = []) throws { + guard !tag.isEmpty, Array(tag.utf8).count < VirtioFS.tagByteCount else { + throw VMError.invalidConfiguration("invalid virtio-fs share tag: \(tag)") + } + guard tag.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-" }) else { + throw VMError.invalidConfiguration("virtio-fs share tag must contain only letters, numbers, '.', '_', or '-'") + } + guard !path.isEmpty else { + throw VMError.invalidConfiguration("virtio-fs share \(tag) has an empty host path") + } + if let guestMountPoint, !guestMountPoint.hasPrefix("/") { + throw VMError.invalidConfiguration("virtio-fs share \(tag) guest mount point must be absolute: \(guestMountPoint)") + } + self.tag = tag + self.path = path + self.readOnly = readOnly + self.dax = dax + self.guestMountPoint = guestMountPoint + self.hiddenNames = hiddenNames + } + + public init(argument: String) throws { + let split = argument.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard split.count == 2 else { + throw VMError.invalidConfiguration("share must be tag=/host/path[:ro|:rw][:dax]") + } + let tag = String(split[0]) + var components = String(split[1]).components(separatedBy: ":") + var path = components.removeFirst() + var readOnly = false + var dax = false + var guestMountPoint: String? + var hiddenNames: Set = [] + for option in components { + switch option { + case "ro": readOnly = true + case "rw": readOnly = false + case "dax": dax = true + case "safe": hiddenNames.formUnion(Self.sensitiveNames) + case "": path += ":" + case let option where option.hasPrefix("at="): + guestMountPoint = String(option.dropFirst(3)) + case let option where option.hasPrefix("hide="): + hiddenNames.formUnion(option.dropFirst(5).split(separator: ",").map(String.init)) + default: + throw VMError.invalidConfiguration("unknown virtio-fs share option ':\(option)' (expected ro, rw, dax, safe, hide=a,b, or at=/guest/path)") + } + } + try self.init(tag: tag, path: path, readOnly: readOnly, dax: dax, guestMountPoint: guestMountPoint, hiddenNames: hiddenNames) + } + + public func makeBackend(daxGuestBase: UInt64? = nil) throws -> VirtioFS { + let hostFS = try HostFS(rootPath: path, readOnly: readOnly, hiddenNames: hiddenNames) + guard dax else { + return try VirtioFS(tag: tag, hostFS: hostFS) + } + guard let daxGuestBase else { + throw VMError.invalidConfiguration("virtio-fs share \(tag) requests dax but no guest window base was allocated") + } + return try VirtioFS(tag: tag, hostFS: hostFS, daxConfiguration: VirtioFSDaxConfiguration(guestBase: daxGuestBase)) + } +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift new file mode 100644 index 0000000..b645a87 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioMMIO.swift @@ -0,0 +1,230 @@ +import Foundation + +/// A virtio device backend: owns semantics, the transport owns the rings and registers. +public protocol VirtioDeviceBackend: AnyObject { + var deviceID: UInt32 { get } + var deviceFeatures: UInt64 { get } + var queueCount: Int { get } + var configSpace: [UInt8] { get } + /// Called on a queue notify; process available chains and push used ones. + func handleKick(queue: Int, transport: VirtioMMIOTransport) + /// Driver finished feature negotiation and set DRIVER_OK. + func deviceReady(transport: VirtioMMIOTransport) + func writeConfig(offset: UInt64, value: UInt64, width: Int) +} + +extension VirtioDeviceBackend { + public func deviceReady(transport: VirtioMMIOTransport) {} + public func writeConfig(offset: UInt64, value: UInt64, width: Int) {} +} + +public struct VirtioSharedMemoryRegion: Equatable, Sendable { + public var id: UInt32 + public var guestBase: UInt64 + public var length: UInt64 + + public init(id: UInt32, guestBase: UInt64, length: UInt64) { + self.id = id + self.guestBase = guestBase + self.length = length + } +} + +public protocol VirtioSharedMemoryRegionProvider: AnyObject { + var sharedMemoryRegions: [VirtioSharedMemoryRegion] { get } +} + +/// virtio-mmio v2 transport (virtio spec 1.2, section 4.2). One instance per bus slot. +public final class VirtioMMIOTransport: MMIODevice { + public let baseAddress: UInt64 + public let size: UInt64 = GuestLayout.virtioSlotSize + public let backend: VirtioDeviceBackend + public private(set) var queues: [Virtqueue] + public private(set) var negotiatedFeatures: UInt64 = 0 + + private let memory: GuestMemory + private let interrupt: () -> Void + private var deviceFeatureSelect: UInt32 = 0 + private var driverFeatureSelect: UInt32 = 0 + private var driverFeatures: UInt64 = 0 + private var queueSelect: Int = 0 + private var sharedMemorySelect: UInt32 = 0 + private var status: UInt32 = 0 + private var interruptStatus: UInt32 = 0 + private let interruptLock = NSLock() // device backends may complete buffers off the vCPU thread + private let registerLock = NSRecursiveLock() // SMP: register access and kicks arrive from any vCPU thread + private var pendingQueueLayout: [(descriptor: UInt64, avail: UInt64, used: UInt64, count: UInt16)] + + private static let magic: UInt64 = 0x7472_6976 // "virt" + private static let vendor: UInt64 = 0x792D_726F_64 // "dor-y" + private static let version1Feature: UInt64 = 1 << 32 + + public init( + baseAddress: UInt64, + backend: VirtioDeviceBackend, + memory: GuestMemory, + interrupt: @escaping () -> Void + ) { + self.baseAddress = baseAddress + self.backend = backend + self.memory = memory + self.interrupt = interrupt + self.queues = (0..(_ body: () -> T) -> T { + registerLock.lock() + defer { registerLock.unlock() } + return body() + } + + public func read(offset: UInt64, width: Int) -> UInt64 { + registerLock.lock() + defer { registerLock.unlock() } + switch offset { + case 0x000: return Self.magic + case 0x004: return 2 + case 0x008: return UInt64(backend.deviceID) + case 0x00C: return Self.vendor + case 0x010: + let features = backend.deviceFeatures | Self.version1Feature + return deviceFeatureSelect == 0 ? features & 0xFFFF_FFFF : features >> 32 + case 0x034: return 256 // QueueNumMax + case 0x044: + guard queueSelect < queues.count else { return 0 } + return queues[queueSelect].ready ? 1 : 0 + case 0x060: + interruptLock.lock() + defer { interruptLock.unlock() } + return UInt64(interruptStatus) + case 0x070: return UInt64(status) + case 0x0B0: return selectedSharedMemoryRegion?.length.lowUInt32 ?? UInt64(UInt32.max) + case 0x0B4: return selectedSharedMemoryRegion?.length.highUInt32 ?? UInt64(UInt32.max) + case 0x0B8: return selectedSharedMemoryRegion?.guestBase.lowUInt32 ?? 0 + case 0x0BC: return selectedSharedMemoryRegion?.guestBase.highUInt32 ?? 0 + case 0x0FC: return 0 // ConfigGeneration + case 0x100...: + return readConfig(offset: offset - 0x100, width: width) + default: + return 0 + } + } + + public func write(offset: UInt64, value: UInt64, width: Int) { + registerLock.lock() + defer { registerLock.unlock() } + switch offset { + case 0x014: deviceFeatureSelect = UInt32(truncatingIfNeeded: value) + case 0x020: + if driverFeatureSelect == 0 { + driverFeatures = (driverFeatures & ~0xFFFF_FFFF) | (value & 0xFFFF_FFFF) + } else { + driverFeatures = (driverFeatures & 0xFFFF_FFFF) | (value << 32) + } + case 0x024: driverFeatureSelect = UInt32(truncatingIfNeeded: value) + case 0x030: queueSelect = Int(value) + case 0x038: + withSelectedQueue { index in + pendingQueueLayout[index].count = UInt16(clamping: value) + } + case 0x044: + withSelectedQueue { index in + if value & 1 == 1 { + let layout = pendingQueueLayout[index] + queues[index].configure( + size: layout.count, + descriptorTable: layout.descriptor, + availRing: layout.avail, + usedRing: layout.used + ) + queues[index].setReady(true) + } else { + queues[index].setReady(false) + } + } + case 0x050: + let queue = Int(value) + if queue < queues.count { + backend.handleKick(queue: queue, transport: self) + } + case 0x064: + interruptLock.lock() + interruptStatus &= ~UInt32(truncatingIfNeeded: value) + interruptLock.unlock() + case 0x070: + status = UInt32(truncatingIfNeeded: value) + if status == 0 { + resetDevice() + } else if status & 0x4 != 0 { // DRIVER_OK + negotiatedFeatures = driverFeatures + backend.deviceReady(transport: self) + } + case 0x0AC: sharedMemorySelect = UInt32(truncatingIfNeeded: value) + case 0x080: withSelectedQueue { pendingQueueLayout[$0].descriptor = merge(pendingQueueLayout[$0].descriptor, low: value) } + case 0x084: withSelectedQueue { pendingQueueLayout[$0].descriptor = merge(pendingQueueLayout[$0].descriptor, high: value) } + case 0x090: withSelectedQueue { pendingQueueLayout[$0].avail = merge(pendingQueueLayout[$0].avail, low: value) } + case 0x094: withSelectedQueue { pendingQueueLayout[$0].avail = merge(pendingQueueLayout[$0].avail, high: value) } + case 0x0A0: withSelectedQueue { pendingQueueLayout[$0].used = merge(pendingQueueLayout[$0].used, low: value) } + case 0x0A4: withSelectedQueue { pendingQueueLayout[$0].used = merge(pendingQueueLayout[$0].used, high: value) } + case 0x100...: + backend.writeConfig(offset: offset - 0x100, value: value, width: width) + default: + break + } + } + + private func resetDevice() { + for queue in queues { queue.reset() } + pendingQueueLayout = Array(repeating: (0, 0, 0, 0), count: backend.queueCount) + interruptStatus = 0 + negotiatedFeatures = 0 + driverFeatures = 0 + } + + private func withSelectedQueue(_ body: (Int) -> Void) { + guard queueSelect < queues.count else { return } + body(queueSelect) + } + + private func merge(_ current: UInt64, low: UInt64) -> UInt64 { + (current & ~0xFFFF_FFFF) | (low & 0xFFFF_FFFF) + } + + private func merge(_ current: UInt64, high: UInt64) -> UInt64 { + (current & 0xFFFF_FFFF) | (high << 32) + } + + private func readConfig(offset: UInt64, width: Int) -> UInt64 { + let config = backend.configSpace + var value: UInt64 = 0 + for byteIndex in 0..> 32 } +} + +extension VirtioMMIOTransport: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift new file mode 100644 index 0000000..33e97fc --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/VirtioNet.swift @@ -0,0 +1,125 @@ +import Darwin +import Foundation + +/// virtio-net wired to a userspace network stack (gvproxy) over a unix datagram socket, one +/// ethernet frame per datagram (the vfkit protocol). No offloads: VERSION_1 + MAC only, so the +/// 12-byte header is constant and the stack stays trivially small. No host entitlement needed. +public final class VirtioNet: VirtioDeviceBackend { + public let deviceID: UInt32 = 1 + public let queueCount = 2 // 0 = receive, 1 = transmit + public var deviceFeatures: UInt64 { 1 << 5 } // VIRTIO_NET_F_MAC + + /// gvproxy's canonical vfkit guest MAC; its DHCP hands this MAC 192.168.127.2. + public static let guestMAC: [UInt8] = [0x5A, 0x94, 0xEF, 0xE4, 0x0C, 0xEE] + + private static let headerLength = 12 + private let socketFD: Int32 + private var receiveSource: (any DispatchSourceRead)? + private weak var transport: VirtioMMIOTransport? + private let receiveQueue = DispatchQueue(label: "dory-hv.net.rx") + + public init(socketPath: String, remotePath: String) throws { + let descriptor = socket(AF_UNIX, SOCK_DGRAM, 0) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration("cannot create datagram socket: errno \(errno)") + } + unlink(socketPath) + var local = sockaddr_un() + local.sun_family = sa_family_t(AF_UNIX) + Self.copyPath(socketPath, into: &local) + let bindResult = withUnsafePointer(to: &local) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in + bind(descriptor, address, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + close(descriptor) + throw VMError.invalidConfiguration("cannot bind \(socketPath): errno \(errno)") + } + var remote = sockaddr_un() + remote.sun_family = sa_family_t(AF_UNIX) + Self.copyPath(remotePath, into: &remote) + let connectResult = withUnsafePointer(to: &remote) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in + connect(descriptor, address, socklen_t(MemoryLayout.size)) + } + } + guard connectResult == 0 else { + close(descriptor) + throw VMError.invalidConfiguration("cannot connect \(remotePath): errno \(errno)") + } + var bufferSize = 1 << 20 + setsockopt(descriptor, SOL_SOCKET, SO_SNDBUF, &bufferSize, socklen_t(MemoryLayout.size)) + setsockopt(descriptor, SOL_SOCKET, SO_RCVBUF, &bufferSize, socklen_t(MemoryLayout.size)) + self.socketFD = descriptor + } + + deinit { + receiveSource?.cancel() + close(socketFD) + } + + public var configSpace: [UInt8] { Self.guestMAC } + + public func deviceReady(transport: VirtioMMIOTransport) { + self.transport = transport + guard receiveSource == nil else { return } + let source = DispatchSource.makeReadSource(fileDescriptor: socketFD, queue: receiveQueue) + source.setEventHandler { [weak self] in + self?.drainSocket() + } + source.resume() + receiveSource = source + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + guard queue == 1 else { return } + let virtqueue = transport.queues[1] + var interrupt = false + while let chain = (try? virtqueue.pop()) ?? nil { + let frame = chain.readBytes() + if frame.count > Self.headerLength { + frame[Self.headerLength...].withUnsafeBytes { buffer in + _ = send(socketFD, buffer.baseAddress, buffer.count, 0) + } + } + let wants = (try? virtqueue.push(chain, written: 0)) ?? false + interrupt = interrupt || wants + } + if interrupt { + transport.notifyUsed() + } + } + + private func drainSocket() { + guard let transport else { return } + let virtqueue = transport.queues[0] + var frame = [UInt8](repeating: 0, count: 65536) + var interrupt = false + while true { + let received = recv(socketFD, &frame, frame.count, MSG_DONTWAIT) + guard received > 0 else { break } + // Serialize the pop/copy/push against any vCPU thread reconfiguring or resetting this + // queue via MMIO; without this, a concurrent reset (size -> 0) traps the VMM. + let wants = transport.withQueueLock { () -> Bool in + guard let chain = (try? virtqueue.pop()) ?? nil else { return false } // no buffer: drop + var packet = [UInt8](repeating: 0, count: Self.headerLength) + packet[10] = 1 // num_buffers = 1 + packet.append(contentsOf: frame[0..) throws { + let data = Array(bytes) + guard data.count >= Self.byteCount else { + throw VMError.invalidConfiguration("short virtio-vsock header") + } + func le16(_ offset: Int) -> UInt16 { + UInt16(data[offset]) | (UInt16(data[offset + 1]) << 8) + } + func le32(_ offset: Int) -> UInt32 { + UInt32(data[offset]) | (UInt32(data[offset + 1]) << 8) + | (UInt32(data[offset + 2]) << 16) | (UInt32(data[offset + 3]) << 24) + } + func le64(_ offset: Int) -> UInt64 { + UInt64(le32(offset)) | (UInt64(le32(offset + 4)) << 32) + } + let rawOperation = le16(30) + self.init( + sourceCID: le64(0), + destinationCID: le64(8), + sourcePort: le32(16), + destinationPort: le32(20), + length: le32(24), + type: le16(28), + operation: Operation(rawValue: rawOperation) ?? .invalid, + flags: le32(32), + bufferAllocation: le32(36), + forwardCount: le32(40) + ) + } + + public func encoded() -> [UInt8] { + var bytes = [UInt8]() + func appendLE(_ value: T) { + var little = value.littleEndian + withUnsafeBytes(of: &little) { bytes.append(contentsOf: $0) } + } + appendLE(sourceCID) + appendLE(destinationCID) + appendLE(sourcePort) + appendLE(destinationPort) + appendLE(length) + appendLE(type) + appendLE(operation.rawValue) + appendLE(flags) + appendLE(bufferAllocation) + appendLE(forwardCount) + return bytes + } + + public func reply(operation: Operation, length: UInt32 = 0, forwardCount: UInt32? = nil) -> VirtioVsockHeader { + VirtioVsockHeader( + sourceCID: destinationCID, + destinationCID: sourceCID, + sourcePort: destinationPort, + destinationPort: sourcePort, + length: length, + type: type, + operation: operation, + flags: flags, + bufferAllocation: bufferAllocation, + forwardCount: forwardCount ?? self.forwardCount + ) + } +} + +public protocol VsockConnection: AnyObject { + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int + func write(_ bytes: [UInt8]) throws + func close() + /// True once the peer has shut the connection down (or it was reset). `read` returns 0 both when + /// no bytes are buffered yet and after the peer is gone, so a long-lived reader (e.g. the USB + /// bridge) needs this to tell "idle" from EOF — without it a claimed device can never be released + /// on guest reboot. + var isPeerClosed: Bool { get } +} + +public enum VsockPorts { + public static let agent: UInt32 = 1024 + public static let usbip: UInt32 = 1025 +} + +public final class VirtioVsock: VirtioDeviceBackend { + public let deviceID: UInt32 = 19 + public let queueCount = 3 + public let deviceFeatures: UInt64 = 0 + public var configSpace: [UInt8] { + var bytes = [UInt8]() + var cid = UInt64(guestCID).littleEndian + withUnsafeBytes(of: &cid) { bytes.append(contentsOf: $0) } + return bytes + } + + private let guestCID: UInt32 + private let stateLock = NSLock() + private var listeners: [UInt32: (VsockConnection) -> Void] = [:] + private var connections: [ConnectionKey: InProcessConnection] = [:] + private var pendingGuestPackets: [[UInt8]] = [] + private var nextHostPort: UInt32 = 49_152 + private weak var lastTransport: VirtioMMIOTransport? + + private struct ConnectionKey: Hashable { + var guestPort: UInt32 + var hostPort: UInt32 + } + + public init(guestCID: UInt32) { + self.guestCID = guestCID + } + + private func withLock(_ body: () -> T) -> T { + stateLock.lock() + defer { stateLock.unlock() } + return body() + } + + public func listen(port: UInt32, handler: @escaping (VsockConnection) -> Void) { + withLock { listeners[port] = handler } + } + + public func connect(port guestPort: UInt32) -> VsockConnection { + let (key, connection) = withLock { () -> (ConnectionKey, InProcessConnection) in + let hostPort = allocateHostPortLocked() + let key = ConnectionKey(guestPort: guestPort, hostPort: hostPort) + let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount in + self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount) + } onClose: { [weak self] key in + self?.removeConnection(key: key) + } + connections[key] = connection + return (key, connection) + } + enqueueHostPacket(key: key, operation: .request) + return connection + } + + private func removeConnection(key: ConnectionKey) { + withLock { _ = connections.removeValue(forKey: key) } + } + + public func drainPendingGuestPackets() -> [[UInt8]] { + withLock { + defer { pendingGuestPackets.removeAll() } + return pendingGuestPackets + } + } + + public func handleKick(queue: Int, transport: VirtioMMIOTransport) { + withLock { lastTransport = transport } + if queue == 0 { + flushPendingGuestPackets(transport: transport) + return + } + guard queue == 1 else { return } + let virtqueue = transport.queues[1] + var interrupt = false + while let chain = (try? virtqueue.pop()) ?? nil { + let packet = chain.readBytes() + let responses = (try? receive(packet: packet)) ?? [] + for response in responses { + if let rx = (try? transport.queues[0].pop()) ?? nil { + let written = rx.writeBytes(response) + let wants = (try? transport.queues[0].push(rx, written: written)) ?? false + interrupt = interrupt || wants + } + } + let wants = (try? virtqueue.push(chain, written: 0)) ?? false + interrupt = interrupt || wants + } + if interrupt { + transport.notifyUsed() + } + } + + public func deviceReady(transport: VirtioMMIOTransport) { + withLock { lastTransport = transport } + } + + private func flushPendingGuestPackets(transport: VirtioMMIOTransport) { + var interrupt = false + while withLock({ !pendingGuestPackets.isEmpty }), let rx = (try? transport.queues[0].pop()) ?? nil { + let packet = withLock { pendingGuestPackets.removeFirst() } + let written = rx.writeBytes(packet) + let wants = (try? transport.queues[0].push(rx, written: written)) ?? false + interrupt = interrupt || wants + } + if interrupt { + transport.notifyUsed() + } + } + + public func receive(packet: [UInt8]) throws -> [[UInt8]] { + let header = try VirtioVsockHeader(decoding: packet.prefix(VirtioVsockHeader.byteCount)) + let payload = Array(packet.dropFirst(VirtioVsockHeader.byteCount)) + let key = ConnectionKey(guestPort: header.sourcePort, hostPort: header.destinationPort) + switch header.operation { + case .request: + let listener = withLock { listeners[header.destinationPort] } + guard let listener else { + return [header.reply(operation: .reset).encoded()] + } + let connection = withLock { () -> InProcessConnection in + let connection = InProcessConnection(key: key) { [weak self] operation, payload, forwardCount in + self?.enqueueHostPacket(key: key, operation: operation, payload: payload, forwardCount: forwardCount) + } onClose: { [weak self] key in + self?.removeConnection(key: key) + } + connections[key] = connection + return connection + } + listener(connection) + return [header.reply(operation: .response).encoded()] + case .readWrite: + let connection = withLock { connections[key] } + guard let connection, UInt32(payload.count) <= header.bufferAllocation else { + return [header.reply(operation: .reset).encoded()] + } + connection.receive(payload) + return [header.reply(operation: .creditUpdate, forwardCount: connection.forwardCount).encoded()] + case .shutdown, .reset: + withLock { connections.removeValue(forKey: key) }?.close() + return [header.reply(operation: .shutdown).encoded()] + case .creditRequest: + return [header.reply(operation: .creditUpdate).encoded()] + case .response: + return [] + case .creditUpdate: + return [] + case .invalid: + return [] + } + } + + private func allocateHostPortLocked() -> UInt32 { + defer { nextHostPort &+= 1 } + return nextHostPort + } + + private func enqueueHostPacket( + key: ConnectionKey, + operation: VirtioVsockHeader.Operation, + payload: [UInt8] = [], + forwardCount: UInt32 = 0 + ) { + let header = VirtioVsockHeader( + sourceCID: 2, + destinationCID: UInt64(guestCID), + sourcePort: key.hostPort, + destinationPort: key.guestPort, + length: UInt32(payload.count), + operation: operation, + forwardCount: forwardCount + ) + let transport = withLock { () -> VirtioMMIOTransport? in + pendingGuestPackets.append(header.encoded() + payload) + return lastTransport + } + if let transport { + transport.withQueueLock { + flushPendingGuestPackets(transport: transport) + } + } + } + + private final class InProcessConnection: VsockConnection { + let key: ConnectionKey + private let send: (VirtioVsockHeader.Operation, [UInt8], UInt32) -> Void + private let onClose: (ConnectionKey) -> Void + // `receive` runs on the vsock queue while `read`/`close` may run on a bridge's own thread, so + // inbound + isClosed are guarded. Drained bytes still count toward forwardCount (credit), so + // it is tracked separately from what remains buffered. + private let lock = NSLock() + private var inbound = [UInt8]() + private var forwardCountValue: UInt32 = 0 + private var isClosed = false + + var forwardCount: UInt32 { lock.lock(); defer { lock.unlock() }; return forwardCountValue } + var isPeerClosed: Bool { lock.lock(); defer { lock.unlock() }; return isClosed } + + init( + key: ConnectionKey, + send: @escaping (VirtioVsockHeader.Operation, [UInt8], UInt32) -> Void, + onClose: @escaping (ConnectionKey) -> Void + ) { + self.key = key + self.send = send + self.onClose = onClose + } + + func receive(_ bytes: [UInt8]) { + lock.lock() + inbound.append(contentsOf: bytes) + forwardCountValue &+= UInt32(bytes.count) + lock.unlock() + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + lock.lock() + defer { lock.unlock() } + let count = min(buffer.count, inbound.count) + guard count > 0 else { return 0 } + inbound.prefix(count).withUnsafeBytes { source in + buffer.baseAddress?.copyMemory(from: source.baseAddress!, byteCount: count) + } + inbound.removeFirst(count) + return count + } + + func write(_ bytes: [UInt8]) throws { + lock.lock() + let closed = isClosed + let credit = forwardCountValue + lock.unlock() + guard !closed else { return } + send(.readWrite, bytes, credit) + } + + func close() { + lock.lock() + if isClosed { lock.unlock(); return } + isClosed = true + let credit = forwardCountValue + inbound.removeAll() + lock.unlock() + send(.shutdown, [], credit) + onClose(key) + } + } +} + +extension VirtioVsock: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift new file mode 100644 index 0000000..f1d05dc --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHV/Virtqueue.swift @@ -0,0 +1,173 @@ +import Foundation +import Synchronization + +/// One buffer segment of a descriptor chain, resolved to host memory. +public struct VirtqueueSegment { + public let pointer: UnsafeMutableRawPointer + public let length: Int + public let isDeviceWritable: Bool +} + +/// A popped descriptor chain: read segments first, then device-writable ones. +public struct VirtqueueChain { + public let head: UInt16 + public let segments: [VirtqueueSegment] + + public var readableSegments: [VirtqueueSegment] { segments.filter { !$0.isDeviceWritable } } + public var writableSegments: [VirtqueueSegment] { segments.filter { $0.isDeviceWritable } } + + public func readBytes(maximum: Int = Int.max) -> [UInt8] { + var bytes = [UInt8]() + for segment in segments where !segment.isDeviceWritable { + let take = min(segment.length, maximum - bytes.count) + guard take > 0 else { break } + bytes.append(contentsOf: UnsafeRawBufferPointer(start: segment.pointer, count: take)) + } + return bytes + } + + @discardableResult + public func writeBytes(_ bytes: [UInt8]) -> Int { + var offset = 0 + for segment in segments where segment.isDeviceWritable { + let take = min(segment.length, bytes.count - offset) + guard take > 0 else { break } + bytes[offset..<(offset + take)].withUnsafeBytes { source in + segment.pointer.copyMemory(from: source.baseAddress!, byteCount: take) + } + offset += take + } + return offset + } +} + +/// Split virtqueue (virtio 1.x basic layout). Descriptor chains are resolved against guest RAM +/// with bounds checks on every dereference; a malformed address from the guest fails the pop +/// rather than touching host memory outside the RAM window. +/// +/// Chain processing runs synchronously on the vCPU thread that kicked the queue, so guest and +/// device never race on ring indices in the single-CPU configuration; SMP adds explicit fences +/// at the used-index publish below. +public final class Virtqueue { + public private(set) var size: UInt16 = 0 + public private(set) var ready = false + private var descriptorTable: UInt64 = 0 + private var availRing: UInt64 = 0 + private var usedRing: UInt64 = 0 + private var lastAvailIndex: UInt16 = 0 + private var usedIndex: UInt16 = 0 + private let memory: GuestMemory + + private struct DescriptorFlags { + static let next: UInt16 = 1 + static let write: UInt16 = 2 + static let indirect: UInt16 = 4 + } + + public init(memory: GuestMemory) { + self.memory = memory + } + + public func configure(size: UInt16, descriptorTable: UInt64, availRing: UInt64, usedRing: UInt64) { + self.size = size + self.descriptorTable = descriptorTable + self.availRing = availRing + self.usedRing = usedRing + } + + public func setReady(_ isReady: Bool) { + ready = isReady + if isReady { + lastAvailIndex = 0 + usedIndex = 0 + } + } + + public func reset() { + ready = false + size = 0 + descriptorTable = 0 + availRing = 0 + usedRing = 0 + lastAvailIndex = 0 + usedIndex = 0 + } + + public var hasPending: Bool { + guard ready, size > 0 else { return false } + let availIndex = (try? memory.read(UInt16.self, at: availRing + 2)) ?? lastAvailIndex + return availIndex != lastAvailIndex + } + + public func pop() throws -> VirtqueueChain? { + guard ready, size > 0 else { return nil } + let availIndex = try memory.read(UInt16.self, at: availRing + 2) + guard availIndex != lastAvailIndex else { return nil } + + let slot = UInt64(lastAvailIndex % size) + let head = try memory.read(UInt16.self, at: availRing + 4 + slot * 2) + lastAvailIndex &+= 1 + + var segments = [VirtqueueSegment]() + try walkChain(startingAt: head, table: descriptorTable, tableSize: size, into: &segments, depth: 0) + return VirtqueueChain(head: head, segments: segments) + } + + private func walkChain( + startingAt first: UInt16, + table: UInt64, + tableSize: UInt16, + into segments: inout [VirtqueueSegment], + depth: Int + ) throws { + guard depth < 4 else { + throw VMError.unexpectedExit("virtqueue indirect descriptor nesting too deep") + } + var index = first + var hops = 0 + while true { + guard hops <= Int(tableSize), index < tableSize else { + throw VMError.unexpectedExit("virtqueue descriptor chain out of bounds") + } + hops += 1 + let base = table + UInt64(index) * 16 + let address = try memory.read(UInt64.self, at: base) + let length = try memory.read(UInt32.self, at: base + 8) + let flags = try memory.read(UInt16.self, at: base + 12) + let next = try memory.read(UInt16.self, at: base + 14) + + if flags & DescriptorFlags.indirect != 0 { + let entries = UInt16(length / 16) + try walkChain(startingAt: 0, table: address, tableSize: entries, into: &segments, depth: depth + 1) + } else if length > 0 { + let pointer = try memory.hostPointer(at: address, count: UInt64(length)) + segments.append(VirtqueueSegment( + pointer: pointer, + length: Int(length), + isDeviceWritable: flags & DescriptorFlags.write != 0 + )) + } + + if flags & DescriptorFlags.indirect != 0 || flags & DescriptorFlags.next == 0 { break } + index = next + } + } + + /// Returns whether the guest asked for an interrupt for this completion. + @discardableResult + public func push(_ chain: VirtqueueChain, written: Int) throws -> Bool { + guard ready, size > 0 else { return false } + let slot = UInt64(usedIndex % size) + try memory.write(UInt32(chain.head), at: usedRing + 4 + slot * 8) + try memory.write(UInt32(written), at: usedRing + 8 + slot * 8) + usedIndex &+= 1 + atomicMemoryFence(ordering: .releasing) // used entries visible before the index publish + try memory.write(usedIndex, at: usedRing + 2) + let availFlags = try memory.read(UInt16.self, at: availRing) + return availFlags & 1 == 0 // VRING_AVAIL_F_NO_INTERRUPT + } +} + +extension VirtqueueSegment: @unchecked Sendable {} +extension VirtqueueChain: @unchecked Sendable {} +extension Virtqueue: @unchecked Sendable {} diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m new file mode 100644 index 0000000..4a4953f --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/DoryHVUSBShim.m @@ -0,0 +1,64 @@ +#import "DoryHVUSBShim.h" + +BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *data, + NSUInteger *bytesTransferred, + NSTimeInterval timeout, + NSError **error) +{ + return [object sendDeviceRequest:request + data:data + bytesTransferred:bytesTransferred + completionTimeout:timeout + error:error]; +} + +BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, + IOUSBHostAbortOption option, + NSError **error) +{ + return [object abortDeviceRequestsWithOption:option error:error]; +} + +BOOL DoryIOUSBHostAbortPipe(IOUSBHostPipe *pipe, + IOUSBHostAbortOption option, + NSError **error) +{ + return [pipe abortWithOption:option error:error]; +} + +IOUSBHostDevice *DoryIOUSBHostCreateDevice(io_service_t service, + IOUSBHostObjectInitOptions options, + NSError **error) +{ + return [[IOUSBHostDevice alloc] initWithIOService:service + options:options + queue:nil + error:error + interestHandler:nil]; +} + +IOUSBHostInterface *DoryIOUSBHostCreateInterface(io_service_t service, + IOUSBHostObjectInitOptions options, + NSError **error) +{ + return [[IOUSBHostInterface alloc] initWithIOService:service + options:options + queue:nil + error:error + interestHandler:nil]; +} + +IOUSBHostPipe *DoryIOUSBHostCopyPipe(IOUSBHostInterface *interface, + NSUInteger address, + NSError **error) +{ + return [interface copyPipeWithAddress:address error:error]; +} + +void DoryIOUSBHostDestroyObject(IOUSBHostObject *object, + IOUSBHostObjectDestroyOptions options) +{ + [object destroyWithOptions:options]; +} diff --git a/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h new file mode 100644 index 0000000..661fe44 --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/DoryHVUSBShim/include/DoryHVUSBShim.h @@ -0,0 +1,37 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +BOOL DoryIOUSBHostSendDeviceRequest(IOUSBHostObject *object, + IOUSBDeviceRequest request, + NSMutableData *_Nullable data, + NSUInteger *_Nullable bytesTransferred, + NSTimeInterval timeout, + NSError *_Nullable *_Nullable error); + +BOOL DoryIOUSBHostAbortDeviceRequests(IOUSBHostObject *object, + IOUSBHostAbortOption option, + NSError *_Nullable *_Nullable error); + +BOOL DoryIOUSBHostAbortPipe(IOUSBHostPipe *pipe, + IOUSBHostAbortOption option, + NSError *_Nullable *_Nullable error); + +IOUSBHostDevice *_Nullable DoryIOUSBHostCreateDevice(io_service_t service, + IOUSBHostObjectInitOptions options, + NSError *_Nullable *_Nullable error); + +IOUSBHostInterface *_Nullable DoryIOUSBHostCreateInterface(io_service_t service, + IOUSBHostObjectInitOptions options, + NSError *_Nullable *_Nullable error); + +IOUSBHostPipe *_Nullable DoryIOUSBHostCopyPipe(IOUSBHostInterface *interface, + NSUInteger address, + NSError *_Nullable *_Nullable error); + +void DoryIOUSBHostDestroyObject(IOUSBHostObject *object, + IOUSBHostObjectDestroyOptions options); + +NS_ASSUME_NONNULL_END diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift new file mode 100644 index 0000000..2b2a50a --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/EngineMode.swift @@ -0,0 +1,560 @@ +import Containerization +import ContainerizationEXT4 +import ContainerizationOCI +import DoryHV +import Foundation +import SystemPackage + +/// `dory-hv engine`: the production mode SharedVMProvisioner spawns. Owns the full lifecycle: +/// pulls docker:dind once, boots the VMM with networking, and publishes the Docker API at the +/// unix socket the app already consumes. +/// +/// Disk layout: the ROOTFS is a throwaway APFS clone of the pristine unpack, recreated every +/// boot so system state can never rot; DOCKER STATE lives on a separate journaled ext4 mounted +/// at /var/lib/docker, so images, containers, and volumes survive restarts and unclean exits. +enum EngineMode { + struct Configuration { + var engineSocket: String + var kernelPath: String + var gvproxyPath: String + var memoryMB: UInt64 + var cpus: Int + var stateDirectory: String + /// Offline builds pass a decompressed engine rootfs here so first launch needs no network; + /// online builds leave it nil and the engine fetches the image once. + var bundledRootfs: String? + var shares: [VirtioFSShareConfiguration] = [] + var directIP: DirectIPBridgeConfiguration? + } + + /// gvproxy pid for the teardown path: stopping the helper must not orphan the sidecar. + nonisolated(unsafe) private static var sidecarPID: pid_t = 0 + nonisolated(unsafe) private static var signalSources: [any DispatchSourceSignal] = [] + + /// SIGTERM/SIGINT ask the guest to power off (sync + unmount + PSCI) through the shutdown + /// socket; the run loop then returns and the process exits cleanly with a consistent disk. + /// If the guest does not stop in time, fall back to killing everything. + private static func installGracefulShutdown(shutdownSocket: String) { + for sig in [SIGTERM, SIGINT] { + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .global()) + source.setEventHandler { + note("shutdown requested, asking the guest to power off…") + // Retry the poweroff touch: SIGTERM can arrive during guest boot, before the + // gvproxy forward is registered or the guest's listener is up. The run loop returns + // once the guest powers off (and the process exits cleanly); the watchdog only + // fires if the guest never stops. + DispatchQueue.global().async { + for _ in 0..<40 { + if touchUnixSocket(shutdownSocket) { return } + usleep(250_000) + } + } + DispatchQueue.global().asyncAfter(deadline: .now() + 12) { + note("guest did not stop in 12s, forcing exit") + if sidecarPID > 0 { kill(sidecarPID, SIGTERM) } + exit(1) + } + } + source.resume() + signalSources.append(source) + } + } + + private static func installClockSyncSignal(vsock: VirtioVsock) { + signal(SIGUSR1, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: SIGUSR1, queue: .global()) + source.setEventHandler { + Task.detached { + await syncGuestClock(vsock: vsock, reason: "signal") + } + } + source.resume() + signalSources.append(source) + } + + private static func syncGuestClock( + vsock: VirtioVsock, + reason: String, + now: @escaping @Sendable () -> Date = Date.init + ) async { + let connection = vsock.connect(port: VsockPorts.agent) + defer { connection.close() } + let transport = AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 2_000_000_000) + let channel = AgentChannel(transport: transport) + let hostEpochNanoseconds = Int64((now().timeIntervalSince1970 * 1_000_000_000).rounded()) + do { + let result = try await channel.syncClock(hostEpochNanoseconds: hostEpochNanoseconds) + note("clock sync \(reason): \(result.synced ? "ok" : "agent declined")") + } catch { + note("clock sync \(reason) failed: \(error)") + } + } + + /// Opens and closes a connection to a unix socket; the guest's listener treats any connection + /// as the power-off request. Returns whether the connection was actually established, so the + /// caller can retry until the forward and the guest listener both exist. + @discardableResult + private static func touchUnixSocket(_ path: String) -> Bool { + let descriptor = socket(AF_UNIX, SOCK_STREAM, 0) + guard descriptor >= 0 else { return false } + defer { close(descriptor) } + var address = sockaddr_un() + address.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &address.sun_path) { destination in + let bytes = [UInt8](path.utf8.prefix(destination.count - 1)) + destination.copyBytes(from: bytes) + } + let result = withUnsafePointer(to: &address) { pointer in + pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { raw in + connect(descriptor, raw, socklen_t(MemoryLayout.size)) + } + } + return result == 0 + } + + static func run(_ configuration: Configuration) async throws { + let state = configuration.stateDirectory + try FileManager.default.createDirectory(atPath: state, withIntermediateDirectories: true) + + let pristineRootfs = state + "/rootfs-pristine.ext4" + let bootRootfs = state + "/rootfs-boot.ext4" + let dataDisk = state + "/docker-data.ext4" + + // Both one-time artifacts are built at a temp path and atomically renamed into place, so an + // interrupted first run leaves no half-written file that the fileExists guard would then + // treat as complete forever. + if !FileManager.default.fileExists(atPath: pristineRootfs) { + let temporary = pristineRootfs + ".partial" + try? FileManager.default.removeItem(atPath: temporary) + if let bundled = configuration.bundledRootfs, FileManager.default.fileExists(atPath: bundled) { + // Offline build: the engine image ships in the app, no network on first launch. + note("first run: installing bundled engine rootfs (one-time, offline)…") + try FileManager.default.copyItem(atPath: bundled, toPath: temporary) + } else { + note("first run: fetching engine image (one-time)…") + try await prepareEngineDisk(at: temporary, stateDirectory: state) + } + try fsyncFile(temporary) + try FileManager.default.moveItem(atPath: temporary, toPath: pristineRootfs) + } + try? FileManager.default.removeItem(atPath: bootRootfs) + try FileManager.default.copyItem(atPath: pristineRootfs, toPath: bootRootfs) + + if !FileManager.default.fileExists(atPath: dataDisk) { + note("first run: creating journaled docker data disk…") + let temporary = dataDisk + ".partial" + try? FileManager.default.removeItem(atPath: temporary) + let formatter = try EXT4.Formatter( + FilePath(temporary), + minDiskSize: 16 * 1024 * 1024 * 1024, + journal: EXT4.JournalConfig.default + ) + try formatter.close() + try fsyncFile(temporary) + try FileManager.default.moveItem(atPath: temporary, toPath: dataDisk) + } + + let machine = try Machine(configuration: MachineConfiguration( + kernelPath: configuration.kernelPath, + commandLine: guestCommandLine(shares: configuration.shares), + memoryBytes: configuration.memoryMB << 20, + cpuCount: configuration.cpus + )) + machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) + machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in + FileHandle.standardOutput.write(Data([byte])) + }) + + var backends: [VirtioDeviceBackend] = [] + backends.append(try VirtioBlk(path: bootRootfs, identity: "dory-rootfs")) + backends.append(try VirtioBlk(path: dataDisk, identity: "dory-data")) + backends.append(VirtioRng()) + backends.append(VirtioBalloon(memory: machine.memory) { note($0) }) + let vsock = VirtioVsock(guestCID: 3) + backends.append(vsock) + var daxSlot: UInt64 = 0 + for share in configuration.shares { + let daxBase = share.dax ? GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize : nil + if share.dax { daxSlot += 1 } + backends.append(try share.makeBackend(daxGuestBase: daxBase)) + note("sharing \(share.path) as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")\(share.dax ? " (dax)" : "")") + } + + let datapathSocket = state + "/net.sock" + let apiSocket = state + "/gvproxy-api.sock" + try? FileManager.default.removeItem(atPath: datapathSocket) + try? FileManager.default.removeItem(atPath: apiSocket) + let gvproxy = Process() + gvproxy.executableURL = URL(fileURLWithPath: configuration.gvproxyPath) + gvproxy.arguments = [ + "-mtu", "1500", + "-listen-vfkit", "unixgram://\(datapathSocket)", + "-listen", "unix://\(apiSocket)", + ] + gvproxy.standardOutput = FileHandle.standardError + gvproxy.standardError = FileHandle.standardError + try gvproxy.run() + sidecarPID = gvproxy.processIdentifier + defer { gvproxy.terminate() } + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: datapathSocket) { break } + usleep(50_000) + } + backends.append(try VirtioNet(socketPath: state + "/vm-net.sock", remotePath: datapathSocket)) + var directIPBridge: DirectIPBridge? + if let config = configuration.directIP { + do { + let bridge = try DirectIPBridge(configuration: DirectIPBridgeConfiguration( + subnetCIDR: config.subnetCIDR, + gateway: config.gateway, + gvproxySocketPath: datapathSocket, + localSocketPath: config.localSocketPath, + interfaceNamePath: config.interfaceNamePath + )) { note($0) } + try bridge.start() + directIPBridge = bridge + } catch { + note("direct-ip disabled: \(error)") + directIPBridge = nil + } + } + + for (slot, backend) in backends.enumerated() { + let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, + backend: backend, + memory: machine.memory + ) { [weak machine] in + machine?.raiseSPI(spi) + } + machine.attachVirtioSlot(transport) + } + + try machine.loadBootPayload() + publishForward(local: configuration.engineSocket, guestPort: 2375, apiSocket: apiSocket, label: "docker socket") + let shutdownSocket = state + "/shutdown.sock" + publishForward(local: shutdownSocket, guestPort: 2377, apiSocket: apiSocket, label: "shutdown channel") + installGracefulShutdown(shutdownSocket: shutdownSocket) + installClockSyncSignal(vsock: vsock) + + // Keep gvproxy's forwards in sync with the ports containers publish, so `docker run -p` is + // reachable from the host across the userspace network. + let portForwarder = PortForwarder( + engineSocket: configuration.engineSocket, + apiSocket: apiSocket, + guestIP: "192.168.127.2", + log: { note($0) } + ) + portForwarder.start() + note("engine starting: \(configuration.memoryMB)MiB ceiling, \(configuration.cpus) cpus, socket \(configuration.engineSocket)") + + // USB passthrough: the listener serves guest usbip dials on VsockPorts.usbip, and the control + // server drives `dory usb attach/detach` — it claims the host device, registers it with the + // manager, and tells the guest agent to dial. The listener is a no-op until a device is claimed. + let usbipManager = UsbipManager() + usbipManager.attachListener(to: vsock) + let usbControlHandler = UsbControlHandler( + manager: usbipManager, + openDevice: { busID, mode in try HostUsbDeviceFactory.open(busID: busID, mode: mode) }, + notifyAttach: { request in + let connection = vsock.connect(port: VsockPorts.agent) + defer { connection.close() } + let channel = AgentChannel(transport: AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 10_000_000_000)) + let _: UsbAgentReply = try await channel.call("usb.attach", request) + }, + notifyDetach: { request in + let connection = vsock.connect(port: VsockPorts.agent) + defer { connection.close() } + let channel = AgentChannel(transport: AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 10_000_000_000)) + let _: UsbAgentReply = try await channel.call("usb.detach", request) + } + ) + let usbControlServer = UsbControlServer(path: configuration.stateDirectory + "/usb-control.sock", handler: usbControlHandler) + do { try usbControlServer.start() } catch { note("usb control server unavailable: \(error)") } + + let agentFeatures = AgentFeatureHandles() + let shares = configuration.shares + Task.detached { + guard await probeAgent(vsock: vsock) else { + note("guest agent unavailable; machine port-forward/clock/fs-relay disabled") + return + } + note("guest agent reachable; enabling machine port-forward and file-event relay") + agentFeatures.setMachinePortTimer(startMachinePortWatcher(vsock: vsock, forwarder: portForwarder)) + agentFeatures.setRelay(startFileEventRelay(shares: shares, vsock: vsock)) + } + + let memory = machine.memory + let gauge = DispatchSource.makeTimerSource(queue: .global()) + gauge.schedule(deadline: .now() + 60, repeating: 60) + gauge.setEventHandler { + let released = memory.releasedBytes.load(ordering: .relaxed) + let restored = memory.restoredBytes.load(ordering: .relaxed) + note("reclaim gauge: released \(released >> 20)MiB, restored \(restored >> 20)MiB, net \(Int64(bitPattern: released &- restored) / 1_048_576)MiB") + } + gauge.resume() + + let stop = try machine.run() + directIPBridge?.stop() + agentFeatures.cancel() + gauge.cancel() + note("engine stopped: \(stop)") + } + + private final class AgentFeatureHandles: @unchecked Sendable { + private let lock = NSLock() + private var machinePortTimer: (any DispatchSourceTimer)? + private var relay: HostFSEventRelay? + + func setMachinePortTimer(_ timer: any DispatchSourceTimer) { + lock.lock() + defer { lock.unlock() } + machinePortTimer = timer + } + + func setRelay(_ relay: HostFSEventRelay?) { + lock.lock() + defer { lock.unlock() } + self.relay = relay + } + + func cancel() { + lock.lock() + defer { lock.unlock() } + machinePortTimer?.cancel() + machinePortTimer = nil + relay?.stop() + relay = nil + } + } + + /// Flushes a freshly written file to stable storage before it is renamed into place, so a + /// crash right after the rename can never expose a file whose contents are still in the page + /// cache (EXT4.Formatter.close does not fsync). + private static func fsyncFile(_ path: String) throws { + let descriptor = open(path, O_RDONLY) + guard descriptor >= 0 else { + throw VMError.invalidConfiguration("cannot open \(path) to fsync: errno \(errno)") + } + defer { close(descriptor) } + guard fcntl(descriptor, F_FULLFSYNC) == 0 || fsync(descriptor) == 0 else { + throw VMError.invalidConfiguration("cannot fsync \(path): errno \(errno)") + } + } + + private static func prepareEngineDisk(at path: String, stateDirectory: String) async throws { + let store = try ImageStore(path: URL(fileURLWithPath: stateDirectory + "/content")) + let image = try await store.get(reference: "docker.io/library/docker:dind", pull: true) + _ = try await EXT4Unpacker(blockSizeInBytes: 8 * 1024 * 1024 * 1024) + .unpack(image, for: Platform(arch: "arm64", os: "linux"), at: URL(fileURLWithPath: path)) + } + + /// Guest boot: mounts (docker state on the journaled /dev/vdb), DHCP through gvproxy, + /// dockerd on unix + tcp 2375 (virtual network only), a shutdown listener on tcp 2377 (any + /// connection triggers sync + poweroff, giving the host a clean-unmount path), and a light + /// page-cache cap so free page reporting (which handles free pages automatically at 16 KiB + /// granularity) has cold pages to hand back when the cache bloats. + private static func guestCommandLine(shares: [VirtioFSShareConfiguration] = []) -> String { + var script = [ + "export PATH=/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin", + "mount -t proc proc /proc", + "mount -t sysfs sys /sys", + "mount -t cgroup2 none /sys/fs/cgroup", + "mount -t tmpfs tmpfs /run", + "mount -t tmpfs tmpfs /tmp", + "mkdir -p /dev/pts", + "mount -t devpts devpts /dev/pts", + "mkdir -p /var/lib/docker", + // Fail hard if the persistent data disk will not mount: never let dockerd write to the + // throwaway rootfs, which the next boot destroys (silent total data loss). Powering off + // surfaces the failure to the host, which falls back to another engine. + "mount -t ext4 /dev/vdb /var/lib/docker || { echo DATA-DISK-MOUNT-FAILED; sync; poweroff -f; }", + "ip link set lo up", + "ip link set eth0 up", + "udhcpc -i eth0 -q >/dev/null 2>&1", + "echo 2 > /sys/module/page_reporting/parameters/page_reporting_order 2>/dev/null", + // Bias the guest toward returning cold cache instead of hoarding it; free page + // reporting then hands the freed pages back to the host automatically. + "echo 200 > /proc/sys/vm/vfs_cache_pressure 2>/dev/null", + "echo 262144 > /proc/sys/vm/min_free_kbytes 2>/dev/null", + ] + script += BinfmtRegistration.bootCommands() + for share in shares { + let tag = shellQuote(share.tag) + let mountPoint = shellQuote(share.guestMountPoint ?? "/mnt/dory/\(share.tag)") + var options = share.readOnly ? "ro" : "rw" + if share.dax { options += ",dax=always" } + script.append("mkdir -p \(mountPoint)") + script.append("mount -t virtiofs -o \(options) \(tag) \(mountPoint) || echo VIRTIOFS-MOUNT-FAILED-\(share.tag)") + } + script += [ + "[ -x /usr/bin/dory-agent ] && ! pgrep -x dory-agent >/dev/null 2>&1 && /usr/bin/dory-agent >/var/log/dory-agent.log 2>&1 & true", + "dockerd -H unix:///var/run/docker.sock -H tcp://0.0.0.0:2375 --tls=false >/var/log/dockerd.log 2>&1 & true", + BinfmtRegistration.dockerFallbackCommand(), + "( while true; do nc -l -p 2377 >/dev/null 2>&1; echo shutdown requested; sync; umount /var/lib/docker 2>/dev/null; sync; poweroff -f; done ) & true", + // Cache cap only. NO compaction (it migrates pages and re-faults the ones free page + // reporting already handed back to the host — pure churn) and NO root memory.reclaim + // (write-rejected on the root cgroup). A gentle pagecache-only drop fires when the + // cache passes ~320 MiB, capping the idle footprint while leaving a healthy working + // set for active containers. + "( while true; do sleep 30; c=$(grep Cached: /proc/meminfo | head -1 | tr -s \\ | cut -d\\ -f2); [ ${c:-0} -gt 327680 ] && echo 1 > /proc/sys/vm/drop_caches 2>/dev/null; done ) & true", + // Hand PID 1 to tini (docker-init, shipped in docker:dind) as a reaping init. exec + // replaces the boot shell in place, so tini keeps PID 1 while dockerd and the loops + // above continue as its children. Container shims double-fork and orphan their exited + // children onto PID 1; tini reaps them, so they never pile up as zombies until PID + // exhaustion. If tini is ever missing, fall back to an idle shell (accepting zombies + // over a failed boot). + "[ -x /usr/local/bin/docker-init ] && exec /usr/local/bin/docker-init -s -- sleep 2147483647", + "while true; do sleep 2147483647; done", + ] + return "console=ttyAMA0 root=/dev/vda rw panic=0 init=/bin/sh -- -c \"\(script.joined(separator: "; "))\"" + } + + private static func shellQuote(_ value: String) -> String { + "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } + + private static func startFileEventRelay(shares: [VirtioFSShareConfiguration], vsock: VirtioVsock) -> HostFSEventRelay? { + guard !shares.isEmpty else { return nil } + let eventShares = shares.map { + HostFSEventShare(hostRoot: $0.path, guestRoot: "/mnt/dory/\($0.tag)") + } + let relay = HostFSEventRelay(shares: eventShares) { paths in + guard !paths.isEmpty else { return } + let connection = vsock.connect(port: VsockPorts.agent) + defer { connection.close() } + let transport = AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 2_000_000_000) + let channel = AgentChannel(transport: transport) + do { + let result = try await channel.sendFSEventBatch(paths: paths) + note("file event relay touched \(result.touched)/\(paths.count) paths") + } catch { + note("file event relay skipped batch: \(error)") + } + } + relay.start() + note("file event relay watching \(shares.count) share(s)") + return relay + } + + private final class ForwardedPortSet: @unchecked Sendable { + private let lock = NSLock() + private var ports = Set() + + func snapshot() -> Set { + lock.lock() + defer { lock.unlock() } + return ports + } + + func insert(_ port: UInt16) { + lock.lock() + defer { lock.unlock() } + ports.insert(port) + } + + func remove(_ port: UInt16) { + lock.lock() + defer { lock.unlock() } + ports.remove(port) + } + } + + private static func reconcileMachinePorts( + snapshot: AgentPortSnapshot, + forwarded: ForwardedPortSet, + forwarder: PortForwarder + ) async { + let desired = Set(snapshot.ports.filter { $0.protocol == "tcp" || $0.protocol == "tcp6" }.map(\.port)) + let alreadyForwarded = forwarded.snapshot() + for port in desired.subtracting(alreadyForwarded) { + if await forwarder.exposeMachinePort(port) { + forwarded.insert(port) + note("machine port forward: exposed 127.0.0.1:\(port)") + } + } + for port in alreadyForwarded.subtracting(desired) { + if await forwarder.unexposeMachinePort(port) { + forwarded.remove(port) + note("machine port forward: released 127.0.0.1:\(port)") + } + } + } + + private static func startMachinePortWatcher(vsock: VirtioVsock, forwarder: PortForwarder) -> any DispatchSourceTimer { + let forwarded = ForwardedPortSet() + let timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now() + 5, repeating: 2) + timer.setEventHandler { + Task.detached { + let connection = vsock.connect(port: VsockPorts.agent) + defer { connection.close() } + let transport = AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 2_000_000_000) + let channel = AgentChannel(transport: transport) + do { + let snapshot = try await channel.watchPorts() + await reconcileMachinePorts(snapshot: snapshot, forwarded: forwarded, forwarder: forwarder) + } catch { + note("machine port watch skipped poll: \(error)") + } + } + } + timer.resume() + note("machine port watcher polling guest agent") + return timer + } + + private static func probeAgent(vsock: VirtioVsock, attempts: Int = 15) async -> Bool { + for attempt in 0.. Void + private let timer: any DispatchSourceTimer + private var exposed = Set() + + init(engineSocket: String, apiSocket: String, guestIP: String, log: @escaping (String) -> Void) { + self.engineSocket = engineSocket + self.apiSocket = apiSocket + self.guestIP = guestIP + self.log = log + self.timer = DispatchSource.makeTimerSource(queue: .global()) + timer.schedule(deadline: .now() + 3, repeating: 2) + timer.setEventHandler { [weak self] in self?.sync() } + } + + func start() { timer.resume() } + + private func sync() { + guard let wanted = publishedPorts() else { return } // docker not ready or unreachable + for port in wanted.subtracting(exposed) where expose(port) { + exposed.insert(port) + log("port forward: 127.0.0.1:\(port) -> container") + } + // Only forget the port once gvproxy confirms the forward is gone; a failed unexpose stays + // tracked and is retried on the next tick, so a stale host forward can't leak. + for port in exposed.subtracting(wanted) where unexpose(port) { + exposed.remove(port) + log("port forward: released 127.0.0.1:\(port)") + } + } + + /// The set of host ports currently published by any running container. + private func publishedPorts() -> Set? { + guard let data = curlData(unixSocket: engineSocket, url: "http://d/v1.41/containers/json"), + let containers = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] else { + return nil + } + var ports = Set() + for container in containers { + guard let list = container["Ports"] as? [[String: Any]] else { continue } + for entry in list { + if let publicPort = entry["PublicPort"] as? Int { ports.insert(publicPort) } + } + } + return ports + } + + private func expose(_ port: Int) -> Bool { + // gvproxy's TCP forward wants a bare host:port remote (no scheme), unlike the unix-socket + // forward used for the docker socket. + curlPost(unixSocket: apiSocket, url: "http://gvproxy/services/forwarder/expose", + body: "{\"local\":\"127.0.0.1:\(port)\",\"remote\":\"\(guestIP):\(port)\",\"protocol\":\"tcp\"}") + } + + private func unexpose(_ port: Int) -> Bool { + curlPost(unixSocket: apiSocket, url: "http://gvproxy/services/forwarder/unexpose", + body: "{\"local\":\"127.0.0.1:\(port)\",\"protocol\":\"tcp\"}") + } + + func exposeMachinePort(_ port: UInt16) async -> Bool { + expose(Int(port)) + } + + func unexposeMachinePort(_ port: UInt16) async -> Bool { + unexpose(Int(port)) + } + + private func curlData(unixSocket: String, url: String) -> Data? { + let task = Process() + let pipe = Pipe() + task.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + task.arguments = ["-s", "--max-time", "3", "--unix-socket", unixSocket, url] + task.standardOutput = pipe + task.standardError = FileHandle.nullDevice + guard (try? task.run()) != nil else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + task.waitUntilExit() + return task.terminationStatus == 0 ? data : nil + } + + @discardableResult + private func curlPost(unixSocket: String, url: String, body: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + task.arguments = ["-s", "-f", "--max-time", "3", "--unix-socket", unixSocket, "-X", "POST", "-d", body, url] + task.standardOutput = FileHandle.nullDevice + task.standardError = FileHandle.nullDevice + guard (try? task.run()) != nil else { return false } + task.waitUntilExit() + return task.terminationStatus == 0 + } +} diff --git a/Packages/ContainerizationEngine/Sources/dory-hv/main.swift b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift new file mode 100644 index 0000000..f1851af --- /dev/null +++ b/Packages/ContainerizationEngine/Sources/dory-hv/main.swift @@ -0,0 +1,453 @@ +import DoryHV +import Foundation + +signal(SIGPIPE, SIG_IGN) + +func fail(_ message: String) -> Never { + FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) + exit(1) +} + +struct Options { + var kernel: String? + var initfs: String? + var memoryMB: UInt64 = 2048 + var cpus: Int = 1 + var commandLine = "console=ttyAMA0 earlycon=pl011,mmio32,0x0c000000 panic=0" + var disks: [String] = [] + var gvproxy: String? + var exposePort: UInt16 = 0 + var timeoutSeconds: UInt64 = 30 + var shares: [VirtioFSShareConfiguration] = [] +} + +func parseOptions(_ arguments: ArraySlice) -> Options { + var options = Options() + var iterator = arguments.makeIterator() + while let argument = iterator.next() { + switch argument { + case "--kernel": options.kernel = iterator.next() + case "--initfs": options.initfs = iterator.next() + case "--mem-mb": options.memoryMB = iterator.next().flatMap(UInt64.init) ?? options.memoryMB + case "--cpus": options.cpus = iterator.next().flatMap(Int.init) ?? options.cpus + case "--cmdline": options.commandLine = iterator.next() ?? options.commandLine + case "--disk": if let disk = iterator.next() { options.disks.append(disk) } + case "--gvproxy": options.gvproxy = iterator.next() + case "--expose-docker": options.exposePort = iterator.next().flatMap(UInt16.init) ?? 0 + case "--timeout-sec": options.timeoutSeconds = iterator.next().flatMap(UInt64.init) ?? options.timeoutSeconds + case "--share": + guard let value = iterator.next() else { fail("--share requires tag=/host/path[:ro|:rw][:dax]") } + do { + options.shares.append(try VirtioFSShareConfiguration(argument: value)) + } catch { + fail("\(error)") + } + default: fail("unknown option \(argument)") + } + } + return options +} + +func exposeDockerPort(apiSocket: String, hostPort: UInt16) { + // gvproxy's forwarder API: expose host 127.0.0.1:hostPort -> guest dockerd tcp 2375. + let body = "{\"local\":\"127.0.0.1:\(hostPort)\",\"remote\":\"192.168.127.2:2375\"}" + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/curl") + task.arguments = [ + "-s", "--unix-socket", apiSocket, + "-X", "POST", "-d", body, + "http://gvproxy/services/forwarder/expose", + ] + task.standardOutput = FileHandle.standardError + task.standardError = FileHandle.standardError + try? task.run() + task.waitUntilExit() + FileHandle.standardError.write(Data("dory-hv: docker api exposed on 127.0.0.1:\(hostPort)\n".utf8)) +} + +private struct EmptyParams: Encodable {} + +private struct AgentInfo: Codable { + var kernel: String + var uptimeSeconds: Int64 + var memoryTotal: UInt64 + var memoryFree: UInt64 + var protocolVersion: Int + + enum CodingKeys: String, CodingKey { + case kernel + case uptimeSeconds = "uptime_seconds" + case memoryTotal = "memory_total" + case memoryFree = "memory_free" + case protocolVersion = "protocol_version" + } +} + +private final class AgentPingResultBox: @unchecked Sendable { + private let lock = NSLock() + private var stored: Result? + + func set(_ result: Result) { + lock.lock() + stored = result + lock.unlock() + } + + func get() -> Result? { + lock.lock() + defer { lock.unlock() } + return stored + } +} + +func attachBackend(_ backend: VirtioDeviceBackend, to machine: Machine, slot: Int) { + let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, + backend: backend, + memory: machine.memory + ) { [weak machine] in + machine?.raiseSPI(spi) + } + machine.attachVirtioSlot(transport) +} + +func runAgentPing(_ options: Options) { + guard let kernel = options.kernel else { fail("agent-ping requires --kernel") } + guard let initfs = options.initfs else { fail("agent-ping requires --initfs") } + guard FileManager.default.fileExists(atPath: kernel) else { fail("kernel not found: \(kernel)") } + guard FileManager.default.fileExists(atPath: initfs) else { fail("initfs not found: \(initfs)") } + + do { + let commandLine = options.commandLine == Options().commandLine + ? "console=ttyAMA0 earlycon=pl011,mmio32,0x0c000000 root=/dev/vda rw panic=0" + : options.commandLine + let machine = try Machine(configuration: MachineConfiguration( + kernelPath: kernel, + commandLine: commandLine, + memoryBytes: options.memoryMB << 20, + cpuCount: options.cpus + )) + machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in + FileHandle.standardError.write(Data([byte])) + }) + machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) + let vsock = VirtioVsock(guestCID: 3) + let backends: [VirtioDeviceBackend] = [ + try VirtioBlk(path: initfs, identity: "dory-initfs"), + VirtioRng(), + VirtioBalloon(memory: machine.memory) { message in + FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) + }, + vsock, + ] + for (slot, backend) in backends.enumerated() { + attachBackend(backend, to: machine, slot: slot) + } + try machine.loadBootPayload() + + let runThread = Thread { + do { + let stop = try machine.run() + FileHandle.standardError.write(Data("dory-hv: guest stopped before agent answered: \(stop)\n".utf8)) + } catch { + FileHandle.standardError.write(Data("dory-hv: guest failed before agent answered: \(error)\n".utf8)) + } + } + runThread.name = "dory-hv.agent-ping.vm" + runThread.start() + + let deadline = DispatchTime.now().uptimeNanoseconds + options.timeoutSeconds * 1_000_000_000 + let semaphore = DispatchSemaphore(value: 0) + let result = AgentPingResultBox() + Task.detached { + while DispatchTime.now().uptimeNanoseconds < deadline { + let connection = vsock.connect(port: VsockPorts.agent) + let transport = AgentVsockTransport(connection: connection, readTimeoutNanoseconds: 2_000_000_000) + let channel = AgentChannel(transport: transport) + do { + let info: AgentInfo = try await channel.call("info", EmptyParams()) + result.set(.success(info)) + semaphore.signal() + return + } catch { + connection.close() + try? await Task.sleep(nanoseconds: 500_000_000) + } + } + result.set(.failure(VMError.bootFailure("guest agent did not answer on vsock port 1024 within \(options.timeoutSeconds)s"))) + semaphore.signal() + } + semaphore.wait() + switch result.get() { + case .success(let info): + let data = try JSONEncoder().encode(info) + print(String(decoding: data, as: UTF8.self)) + exit(0) + case .failure(let error): + fail("\(error)") + case nil: + fail("agent-ping ended without a result") + } + } catch { + fail("\(error)") + } +} + +let arguments = Array(CommandLine.arguments.dropFirst()) +guard let command = arguments.first else { + fail("usage: dory-hv [--kernel path] [--mem-mb N] [--cpus N] [--cmdline s]") +} + +switch command { +case "smoke": + do { + let result = try HVSmoke.run() + print("dory-hv: \(result)") + } catch { + fail("\(error)") + } +case "madvtest": + do { + try MadviseProbe.run() + } catch { + fail("\(error)") + } +case "daxprobe": + do { + let arg = arguments.dropFirst(1).first + let base = arg.flatMap { UInt64($0.hasPrefix("0x") ? String($0.dropFirst(2)) : $0, radix: 16) } + let result = try DaxCoherenceProbe.run(daxGuestBase: base ?? GuestLayout.daxWindowBase) + print("dory-hv: \(result)") + } catch { + fail("\(error)") + } +case "boot": + let options = parseOptions(arguments.dropFirst()) + guard let kernel = options.kernel else { fail("boot requires --kernel") } + do { + let configuration = MachineConfiguration( + kernelPath: kernel, + commandLine: options.commandLine, + memoryBytes: options.memoryMB << 20, + cpuCount: options.cpus + ) + let machine = try Machine(configuration: configuration) + let console = FileHandle.standardOutput + machine.attachConsole(PL011(baseAddress: GuestLayout.uartBase) { byte in + console.write(Data([byte])) + }) + machine.bus.attach(PL031(baseAddress: GuestLayout.rtcBase)) + var backends: [VirtioDeviceBackend] = [] + for (slot, diskPath) in options.disks.enumerated() { + backends.append(try VirtioBlk(path: diskPath, identity: "dory-blk\(slot)")) + } + var daxSlot: UInt64 = 0 + for share in options.shares { + let daxBase = share.dax ? GuestLayout.daxWindowBase + daxSlot * DaxWindow.defaultSize : nil + if share.dax { daxSlot += 1 } + backends.append(try share.makeBackend(daxGuestBase: daxBase)) + FileHandle.standardError.write(Data("dory-hv: sharing \(share.path) as virtiofs tag \(share.tag)\(share.readOnly ? " (ro)" : "")\(share.dax ? " (dax)" : "")\n".utf8)) + } + backends.append(VirtioRng()) + backends.append(VirtioBalloon(memory: machine.memory) { message in + FileHandle.standardError.write(Data("dory-hv: \(message)\n".utf8)) + }) + backends.append(VirtioVsock(guestCID: 3)) + var gvproxyProcess: Process? + if let gvproxyPath = options.gvproxy { + let networkDirectory = NSHomeDirectory() + "/.dory/hv" + try FileManager.default.createDirectory(atPath: networkDirectory, withIntermediateDirectories: true) + let datapathSocket = networkDirectory + "/net.sock" + let apiSocket = networkDirectory + "/gvproxy-api.sock" + try? FileManager.default.removeItem(atPath: datapathSocket) + try? FileManager.default.removeItem(atPath: apiSocket) + + let process = Process() + process.executableURL = URL(fileURLWithPath: gvproxyPath) + process.arguments = [ + "-mtu", "1500", + "-listen-vfkit", "unixgram://\(datapathSocket)", + "-listen", "unix://\(apiSocket)", + ] + process.standardOutput = FileHandle.standardError + process.standardError = FileHandle.standardError + try process.run() + gvproxyProcess = process + for _ in 0..<100 { + if FileManager.default.fileExists(atPath: datapathSocket) { break } + usleep(50_000) + } + let vmSocket = networkDirectory + "/vm-net.sock" + backends.append(try VirtioNet(socketPath: vmSocket, remotePath: datapathSocket)) + FileHandle.standardError.write(Data("dory-hv: networking via gvproxy (\(datapathSocket))\n".utf8)) + + if options.exposePort > 0 { + let port = options.exposePort + DispatchQueue.global().asyncAfter(deadline: .now() + 5) { + exposeDockerPort(apiSocket: apiSocket, hostPort: port) + } + } + } + defer { gvproxyProcess?.terminate() } + for (slot, backend) in backends.enumerated() { + let spi = GuestLayout.virtioFirstIRQ + UInt32(slot) + let transport = VirtioMMIOTransport( + baseAddress: GuestLayout.virtioBase + UInt64(slot) * GuestLayout.virtioSlotSize, + backend: backend, + memory: machine.memory + ) { [weak machine] in + machine?.raiseSPI(spi) + } + machine.attachVirtioSlot(transport) + } + try machine.loadBootPayload() + let stop = try machine.run() + print("\ndory-hv: guest stopped: \(stop)") + } catch { + fail("\(error)") + } +case "agent-ping": + runAgentPing(parseOptions(arguments.dropFirst())) +case "usb": + let subcommand = arguments.dropFirst().first ?? "list" + switch subcommand { + case "list", "ls": + do { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(HostUsbDiscovery.list()) + print(String(decoding: data, as: UTF8.self)) + } catch { + fail("usb list failed: \(error)") + } + case "probe": + // Claim a real host device and drive one GET_DESCRIPTOR control transfer through the exact + // usbip submit path the guest would use — a host-side smoke test with no guest/VM required. + let args = Array(arguments.dropFirst(2)) + guard let busID = args.first else { fail("usage: dory-hv usb probe [userAuthorized|seize|capture]") } + let mode: HostUsbOpenMode + switch args.dropFirst().first { + case "seize": mode = .seize + case "capture", nil: mode = .capture + case "userAuthorized", "user": mode = .userAuthorized + case let other?: fail("unknown mode \(other)") + } + do { + FileHandle.standardError.write(Data("dory-hv: claiming \(busID) mode=\(mode)…\n".utf8)) + let device = try HostUsbDeviceFactory.open(busID: busID, mode: mode) + let command = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 1, deviceID: 0, direction: .in, endpoint: 0), + transferFlags: 0, + transferBufferLength: 18, + startFrame: 0, + numberOfPackets: 0, + interval: 0, + setup: [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00], // GET_DESCRIPTOR(device, 18) + transferBuffer: [] + ) + let reply = try device.submit(command) + let bytes = reply.transferBuffer + print("CLAIM OK. GET_DESCRIPTOR status=\(reply.status) actualLength=\(reply.actualLength) bytes=\(bytes.count)") + if bytes.count >= 12 { + let vid = UInt16(bytes[8]) | (UInt16(bytes[9]) << 8) + let pid = UInt16(bytes[10]) | (UInt16(bytes[11]) << 8) + print(String(format: "device descriptor: bLength=%d bDescriptorType=%d idVendor=0x%04x idProduct=0x%04x", bytes[0], bytes[1], vid, pid)) + } + } catch { + fail("usb probe failed: \(error)") + } + case "attach": + let attachArgs = Array(arguments.dropFirst(2)) + guard let busID = attachArgs.first else { fail("usage: dory-hv usb attach [userAuthorized|seize|capture]") } + let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" + do { + let response = try UsbControlClient.send(UsbControlRequest(cmd: "attach", busid: busID, mode: attachArgs.dropFirst().first), socketPath: controlSocket) + guard response.ok else { fail("usb attach failed: \(response.error ?? "unknown")") } + print("attached \(busID) on vhci port \(response.port ?? -1)") + } catch { + fail("usb attach failed: \(error)") + } + case "detach": + let detachArgs = Array(arguments.dropFirst(2)) + guard let busID = detachArgs.first else { fail("usage: dory-hv usb detach ") } + let controlSocket = "\(NSHomeDirectory())/.dory/hv/usb-control.sock" + do { + let response = try UsbControlClient.send(UsbControlRequest(cmd: "detach", busid: busID), socketPath: controlSocket) + guard response.ok else { fail("usb detach failed: \(response.error ?? "unknown")") } + print("detached \(busID)") + } catch { + fail("usb detach failed: \(error)") + } + default: + fail("usage: dory-hv usb ") + } +case "engine": + var engineSocket = "\(NSHomeDirectory())/.dory/engine.sock" + var kernel: String? + var gvproxy: String? + var memoryMB: UInt64 = 2048 + var cpus = 4 + var rootfs: String? + var shares: [VirtioFSShareConfiguration] = [] + var directIPSubnet: String? + var directIPGateway = "192.168.127.2" + var iterator = arguments.dropFirst().makeIterator() + while let argument = iterator.next() { + switch argument { + case "--engine-sock": engineSocket = iterator.next() ?? engineSocket + case "--kernel": kernel = iterator.next() + case "--gvproxy": gvproxy = iterator.next() + case "--rootfs": rootfs = iterator.next() + case "--mem-mb": memoryMB = iterator.next().flatMap(UInt64.init) ?? memoryMB + case "--cpus": cpus = iterator.next().flatMap(Int.init) ?? cpus + case "--direct-ip": directIPSubnet = directIPSubnet ?? "192.168.215.0/24" + case "--container-subnet": directIPSubnet = iterator.next() + case "--guest-gateway": directIPGateway = iterator.next() ?? directIPGateway + case "--share": + guard let value = iterator.next() else { fail("--share requires tag=/host/path[:ro|:rw][:dax]") } + do { + shares.append(try VirtioFSShareConfiguration(argument: value)) + } catch { + fail("\(error)") + } + default: fail("unknown option \(argument)") + } + } + guard let kernel else { fail("engine requires --kernel") } + guard let gvproxy else { fail("engine requires --gvproxy") } + let configuration = EngineMode.Configuration( + engineSocket: engineSocket, + kernelPath: kernel, + gvproxyPath: gvproxy, + memoryMB: memoryMB, + cpus: cpus, + stateDirectory: "\(NSHomeDirectory())/.dory/hv", + bundledRootfs: rootfs, + shares: shares, + directIP: directIPSubnet.map { + DirectIPBridgeConfiguration( + subnetCIDR: $0, + gateway: directIPGateway, + gvproxySocketPath: "", + localSocketPath: "\(NSHomeDirectory())/.dory/hv/direct-ip.sock", + interfaceNamePath: "\(NSHomeDirectory())/.dory/hv/direct-ip.interface" + ) + } + ) + // Top-level code is implicitly MainActor; a plain Task would inherit it and deadlock behind + // the semaphore below. Detach so the engine runs on the concurrent pool. + let semaphore = DispatchSemaphore(value: 0) + Task.detached { + do { + try await EngineMode.run(configuration) + } catch { + FileHandle.standardError.write(Data("dory-hv: engine failed: \(error)\n".utf8)) + exit(1) + } + semaphore.signal() + } + semaphore.wait() +default: + fail("unknown command \(command)") +} diff --git a/Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift b/Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift index a6efd30..f007731 100644 --- a/Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift +++ b/Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift @@ -136,9 +136,11 @@ struct DoryVM { let userCommand = args.command.joined(separator: " ") let scratchPath = scratch let mounts = args.mounts + // Memory ceiling is env-tunable so heavy amd64 images (SQL Server wants >= 2 GB) can run. + let memoryMB = UInt64(ProcessInfo.processInfo.environment["DORY_VM_MEM_MB"] ?? "") ?? 1024 let configure: @Sendable (inout LinuxContainer.Configuration) -> Void = { config in config.cpus = 2 - config.memoryInBytes = 1024 * 1024 * 1024 + config.memoryInBytes = memoryMB * 1024 * 1024 config.mounts.append(Mount.share(source: scratchPath, destination: "/dory-out")) for (host, guest) in mounts { config.mounts.append(Mount.share(source: host, destination: guest)) } config.process.arguments = ["/bin/sh", "-c", "{ \(userCommand) ; } > /dory-out/stdout 2>&1; echo $? > /dory-out/exit"] @@ -189,7 +191,9 @@ struct DoryVM { } guard network != nil else { throw EngineError.noNetwork } - let vmm = BalloonVMM(inner: VZVirtualMachineManager(kernel: kernel, initialFilesystem: initfs)) + let rosetta = (ProcessInfo.processInfo.environment["DORY_ENGINE_ROSETTA"] ?? "0") == "1" + if rosetta { note("Rosetta x86 translation enabled on shared engine") } + let vmm = BalloonVMM(inner: VZVirtualMachineManager(kernel: kernel, initialFilesystem: initfs, rosetta: rosetta)) var manager = try ContainerManager(vmm: vmm, network: network) // The vmm-injecting constructor uses the default image store; clean its prior container dir. let store = NSHomeDirectory() + "/Library/Application Support/com.apple.containerization" @@ -198,9 +202,15 @@ struct DoryVM { try? FileManager.default.removeItem(atPath: store + "/containers/dory-shared-engine") let hostURL = URL(fileURLWithPath: hostSock) + // Tunable memory policy (env overrides let us measure/right-size without a rebuild). + let bootMemBytes = (UInt64(ProcessInfo.processInfo.environment["DORY_ENGINE_MEM_MB"] ?? "") ?? 2048) * 1024 * 1024 + let headroomBytes = (UInt64(ProcessInfo.processInfo.environment["DORY_ENGINE_HEADROOM_MB"] ?? "") ?? 512) * 1024 * 1024 + let reclaimSec = UInt64(ProcessInfo.processInfo.environment["DORY_ENGINE_RECLAIM_SEC"] ?? "") ?? 5 + note("mem ceiling \(bootMemBytes / 1048576)MiB, headroom \(headroomBytes / 1048576)MiB, reclaim/\(reclaimSec)s") + let configure: @Sendable (inout LinuxContainer.Configuration) -> Void = { config in config.cpus = 4 - config.memoryInBytes = 4 * 1024 * 1024 * 1024 + config.memoryInBytes = bootMemBytes config.cpuOverhead = 0 config.memoryOverhead = 64 * 1024 * 1024 config.useInit = true @@ -246,29 +256,53 @@ struct DoryVM { networking: true, configuration: configure ) + if let ip = container.interfaces.first?.ipv4Address.description.split(separator: "/").first { + let ipPath = (hostSock as NSString).deletingLastPathComponent + "/engine.ip" + try? String(ip).write(toFile: ipPath, atomically: true, encoding: .utf8) + note("vm ip \(ip)") + } try await container.create() try await container.start() note("running — docker socket published at \(hostSock)") // Elastic memory: every 10s, shrink the VM's backing RAM toward actual usage + headroom and // hand the rest back to macOS via the balloon; it grows again automatically under load. - let maxMem: UInt64 = 4 * 1024 * 1024 * 1024 + let maxMem: UInt64 = bootMemBytes + // For measurement: DORY_ENGINE_FORCE_TARGET_MB sets a fixed balloon target every cycle, + // bypassing the (currently flaky) framework statistics() call, to isolate whether inflating + // Apple's traditional balloon actually returns pages to the host process. + let forceTargetBytes = (UInt64(ProcessInfo.processInfo.environment["DORY_ENGINE_FORCE_TARGET_MB"] ?? "")).map { $0 * 1024 * 1024 } let reclaim = Task { while !Task.isCancelled { - try? await Task.sleep(nanoseconds: 10_000_000_000) - guard let used = (try? await container.statistics())?.memory?.usageBytes else { continue } - // Keep generous headroom above real usage so a fast-allocating workload never hits the - // balloon ceiling between cycles; the balloon still reclaims large high-water drops. - let target = min(maxMem, max(1024 * 1024 * 1024, used + 1536 * 1024 * 1024)) + try? await Task.sleep(nanoseconds: reclaimSec * 1_000_000_000) + let target: UInt64 + if let forced = forceTargetBytes { + target = forced + } else if let used = (try? await container.statistics())?.memory?.usageBytes { + target = min(maxMem, max(256 * 1024 * 1024, used + headroomBytes)) + note("reclaim: used \(used / 1048576)MiB") + } else { + note("reclaim: statistics unavailable, skipping") + continue + } + note("reclaim: setting balloon target \(target / 1048576)MiB") try? await container.withVirtualMachineInstance { instance in - guard let vzi = instance as? VZVirtualMachineInstance else { return } + guard let vzi = instance as? VZVirtualMachineInstance else { + FileHandle.standardError.write(Data("dory-engine: reclaim: no VZ instance\n".utf8)); return + } let vm = vzi.vzVirtualMachine let queue = vzi.vmQueue await withCheckedContinuation { (cont: CheckedContinuation) in queue.async { + let count = vm.memoryBalloonDevices.count + let msg: String if let balloon = vm.memoryBalloonDevices.first as? VZVirtioTraditionalMemoryBalloonDevice { balloon.targetVirtualMachineMemorySize = target + msg = "dory-engine: reclaim: balloon set to \(target / 1048576)MiB (devices=\(count))\n" + } else { + msg = "dory-engine: reclaim: NO balloon device on VM (devices=\(count))\n" } + FileHandle.standardError.write(Data(msg.utf8)) cont.resume() } } diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift new file mode 100644 index 0000000..b6c6b77 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import DoryHV + +@Suite struct AgentProtocolTests { + @Test func frameCodecUsesBigEndianLengthPrefix() throws { + let frame = try AgentFrameCodec.encode([1, 2, 3]) + #expect(frame.prefix(4) == [0, 0, 0, 3]) + #expect(try AgentFrameCodec.decodeLength(Array(frame.prefix(4))) == 3) + #expect(Array(frame.dropFirst(4)) == [1, 2, 3]) + } + + @Test func frameCodecRejectsOversizedFrames() throws { + #expect(throws: AgentProtocolError.self) { + _ = try AgentFrameCodec.decodeLength([1, 0, 0, 1]) + } + } + + @Test func callReturnsDecodedResult() async throws { + let transport = StubAgentTransport(responsePayload: #"{"id":1,"result":{"ok":true,"kernel":"6.12.30-dory"}}"#) + let channel = AgentChannel(transport: transport) + let result: PingResult = try await channel.call("ping", EmptyParams()) + + #expect(result.ok) + #expect(result.kernel == "6.12.30-dory") + let request = try transport.decodedRequest() + #expect(request.method == "ping") + #expect(request.id == 1) + } + + @Test func callThrowsRemoteErrors() async throws { + let transport = StubAgentTransport(responsePayload: #"{"id":1,"error":{"code":-32601,"message":"unknown method"}}"#) + let channel = AgentChannel(transport: transport) + + await #expect(throws: AgentProtocolError.remoteError(code: -32601, message: "unknown method")) { + let _: PingResult = try await channel.call("missing", EmptyParams()) + } + } + + @Test func clockSyncUsesAgentTimestampKey() async throws { + let transport = StubAgentTransport(responsePayload: #"{"id":1,"result":{"synced":true}}"#) + let channel = AgentChannel(transport: transport) + + let result = try await channel.syncClock(hostEpochNanoseconds: 1_725_000_000_123_456_789) + let request = try transport.decodedRequest() + let params: ClockParams = try transport.decodedParams() + + #expect(result.synced) + #expect(request.method == "clock.sync") + #expect(params.hostEpochNS == 1_725_000_000_123_456_789) + } + + @Test func vsockTransportReadsExactBytesAcrossFragments() async throws { + let connection = StubVsockConnection(chunks: [[1, 2], [3], [4, 5]]) + let transport = AgentVsockTransport(connection: connection) + + #expect(try await transport.readExact(5) == [1, 2, 3, 4, 5]) + try await transport.writeAll([9, 8, 7]) + #expect(connection.written == [9, 8, 7]) + } + + private struct EmptyParams: Encodable {} + + private struct PingResult: Decodable { + var ok: Bool + var kernel: String + } + + private struct CapturedRequest: Decodable { + var id: Int + var method: String + } + + private struct Envelope: Decodable { + var params: Params + } + + private struct ClockParams: Decodable { + var hostEpochNS: Int64 + } + + private final class StubAgentTransport: AgentByteTransport { + private var response: [UInt8] + private(set) var written = [UInt8]() + + init(responsePayload: String) { + self.response = (try? AgentFrameCodec.encode(Array(responsePayload.utf8))) ?? [] + } + + func readExact(_ count: Int) async throws -> [UInt8] { + let bytes = Array(response.prefix(count)) + response.removeFirst(min(count, response.count)) + return bytes + } + + func writeAll(_ bytes: [UInt8]) async throws { + written.append(contentsOf: bytes) + } + + func decodedRequest() throws -> CapturedRequest { + let length = try AgentFrameCodec.decodeLength(Array(written.prefix(4))) + let payload = Data(written.dropFirst(4).prefix(length)) + return try JSONDecoder().decode(CapturedRequest.self, from: payload) + } + + func decodedParams() throws -> Params { + let length = try AgentFrameCodec.decodeLength(Array(written.prefix(4))) + let payload = Data(written.dropFirst(4).prefix(length)) + return try JSONDecoder().decode(Envelope.self, from: payload).params + } + } + + private final class StubVsockConnection: VsockConnection { + private var chunks: [[UInt8]] + private(set) var written = [UInt8]() + + init(chunks: [[UInt8]]) { + self.chunks = chunks + } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + guard !chunks.isEmpty else { return 0 } + let chunk = chunks.removeFirst() + let count = min(buffer.count, chunk.count) + chunk.prefix(count).withUnsafeBytes { source in + buffer.baseAddress?.copyMemory(from: source.baseAddress!, byteCount: count) + } + if count < chunk.count { + chunks.insert(Array(chunk.dropFirst(count)), at: 0) + } + return count + } + + func write(_ bytes: [UInt8]) throws { + written.append(contentsOf: bytes) + } + + func close() { closed = true } + + private(set) var closed = false + var isPeerClosed: Bool { closed } + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/BinfmtRegistrationTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/BinfmtRegistrationTests.swift new file mode 100644 index 0000000..238bc69 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/BinfmtRegistrationTests.swift @@ -0,0 +1,30 @@ +import Testing +@testable import DoryHV + +struct BinfmtRegistrationTests { + @Test func qemuX8664RegistrationUsesFixBinaryFlagAndStaticInterpreter() { + let line = BinfmtRegistration.qemuX8664RegisterLine + + #expect(line.hasPrefix(":qemu-x86_64:M::")) + #expect(line.contains(#"\x7fELF\x02\x01\x01"#)) + #expect(line.contains(#"\x02\x00\x3e\x00"#)) + #expect(line.contains(":/usr/bin/qemu-x86_64-static:F")) + } + + @Test func bootCommandsMountBinfmtAndRegisterIdempotently() { + let script = BinfmtRegistration.bootCommands().joined(separator: "\n") + + #expect(script.contains("mount -t binfmt_misc")) + #expect(script.contains("[ ! -e /proc/sys/fs/binfmt_misc/qemu-x86_64 ]")) + #expect(script.contains("printf '%b'")) + #expect(script.contains("/proc/sys/fs/binfmt_misc/register")) + } + + @Test func dockerFallbackInstallsAmd64ThroughPrivilegedBinfmtImage() { + let command = BinfmtRegistration.dockerFallbackCommand() + + #expect(command.contains("docker info")) + #expect(command.contains("docker run --privileged --rm tonistiigi/binfmt --install amd64")) + #expect(command.contains("/var/log/dory-binfmt.log")) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DaxWindowTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DaxWindowTests.swift new file mode 100644 index 0000000..4027f03 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DaxWindowTests.swift @@ -0,0 +1,97 @@ +import Testing +@testable import DoryHV + +struct DaxWindowTests { + @Test func setupMappingTracksPageAlignedWindowEntries() throws { + let window = try DaxWindow(guestBase: 0x1_0000_0000, length: 0x20_0000) + + let mapping = try window.setup(FuseSetupMappingIn( + fileHandle: 7, + fileOffset: 0x4000, + length: 0x8000, + flags: 0, + memoryOffset: 0x10_0000 + )) + + #expect(mapping.fileHandle == 7) + #expect(try window.guestAddress(forMemoryOffset: 0x10_0000) == 0x1_0010_0000) + #expect(window.activeMappings == [mapping]) + } + + @Test func setupMappingRejectsUnalignedOutOfBoundsAndOverlaps() throws { + let window = try DaxWindow(guestBase: 0x8000_0000, length: 0x20_000) + _ = try window.setup(FuseSetupMappingIn(fileHandle: 1, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0x4000)) + + #expect(throws: DaxWindowError.unaligned) { + try window.setup(FuseSetupMappingIn(fileHandle: 2, fileOffset: 1, length: 0x4000, flags: 0, memoryOffset: 0x8000)) + } + #expect(throws: DaxWindowError.outOfBounds) { + try window.setup(FuseSetupMappingIn(fileHandle: 2, fileOffset: 0, length: 0x40_000, flags: 0, memoryOffset: 0)) + } + #expect(throws: DaxWindowError.overlap) { + try window.setup(FuseSetupMappingIn(fileHandle: 2, fileOffset: 0, length: 0x8000, flags: 0, memoryOffset: 0)) + } + } + + @Test func removeMappingDropsOverlappingEntriesAndNoOpsOnUnmapped() throws { + let window = try DaxWindow(guestBase: 0x8000_0000, length: 0x20_000) + _ = try window.setup(FuseSetupMappingIn(fileHandle: 1, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0)) + _ = try window.setup(FuseSetupMappingIn(fileHandle: 2, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0x8000)) + + try window.remove(FuseRemoveMappingIn(mappings: [FuseRemoveMappingOne(memoryOffset: 0, length: 0x4000)])) + + #expect(window.activeMappings.map(\.fileHandle) == [2]) + + try window.remove(FuseRemoveMappingIn(mappings: [FuseRemoveMappingOne(memoryOffset: 0, length: 0x4000)])) + #expect(window.activeMappings.map(\.fileHandle) == [2]) + } + + @Test func backendMapsAndUnmapsWithOpenFileDescriptorAndGuestAddress() throws { + let backend = RecordingDaxBackend() + let window = try DaxWindow(guestBase: 0x1_0000_0000, length: 0x20_000, backend: backend) + let request = FuseSetupMappingIn( + fileHandle: 7, + fileOffset: 0x4000, + length: 0x4000, + flags: FuseSetupMappingFlag.read.union(.write).rawValue, + memoryOffset: 0x8000 + ) + + _ = try window.setup(request, fileDescriptor: 42) + + #expect(backend.mapped.count == 1) + #expect(backend.mapped[0].fd == 42) + #expect(backend.mapped[0].guestAddress == 0x1_0000_0000 + 0x8000) + #expect(backend.mapped[0].mapping.fileOffset == 0x4000) + + try window.remove(FuseRemoveMappingIn(mappings: [ + FuseRemoveMappingOne(memoryOffset: 0x8000, length: 0x4000), + ])) + + #expect(backend.unmapped.count == 1) + #expect(backend.unmapped[0].guestAddress == 0x1_0000_0000 + 0x8000) + } + + @Test func backendWindowRejectsSetupWithoutFileDescriptor() throws { + let backend = RecordingDaxBackend() + let window = try DaxWindow(guestBase: 0x1_0000_0000, length: 0x20_000, backend: backend) + + #expect(throws: DaxWindowError.mappingFailed("missing file descriptor")) { + try window.setup(FuseSetupMappingIn(fileHandle: 7, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0)) + } + #expect(backend.mapped.isEmpty) + } +} + +private final class RecordingDaxBackend: DaxMappingBackend, @unchecked Sendable { + var mapped: [(mapping: DaxMapping, fd: Int32, guestAddress: UInt64)] = [] + var unmapped: [(mapping: DaxMapping, guestAddress: UInt64)] = [] + + func map(_ mapping: DaxMapping, fileDescriptor: Int32, guestAddress: UInt64) throws { + mapped.append((mapping, fileDescriptor, guestAddress)) + } + + func unmap(_ mapping: DaxMapping, guestAddress: UInt64) throws { + unmapped.append((mapping, guestAddress)) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift new file mode 100644 index 0000000..c77db95 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DeviceLogicTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing +@testable import DoryHV + +@Suite struct FDTBuilderTests { + @Test func emitsValidFlattenedDeviceTreeHeader() { + let fdt = FDTBuilder() + fdt.beginNode("") + fdt.property("compatible", string: "linux,dummy-virt") + fdt.property("#address-cells", cells: [2]) + fdt.beginNode("memory@40000000") + fdt.property("device_type", string: "memory") + fdt.property("reg", cells64: [0x4000_0000, 0x8000_0000]) + fdt.endNode() + fdt.endNode() + let blob = fdt.finish(bootCPU: 0) + + func beU32(_ offset: Int) -> UInt32 { + (UInt32(blob[offset]) << 24) | (UInt32(blob[offset + 1]) << 16) + | (UInt32(blob[offset + 2]) << 8) | UInt32(blob[offset + 3]) + } + #expect(beU32(0) == 0xD00D_FEED) // magic + #expect(beU32(4) == UInt32(blob.count)) // totalsize matches actual length + #expect(beU32(20) == 17) // version + #expect(beU32(24) == 16) // last_comp_version + // off_dt_struct + size_dt_struct stay within the blob. + let structOffset = Int(beU32(8)) + let structSize = Int(beU32(36)) + #expect(structOffset + structSize <= blob.count) + } + + @Test func stringsAreDeduplicatedInTheStringsBlock() { + let a = FDTBuilder() + a.beginNode("") + a.property("reg", cells: [1]) + a.property("reg", cells: [2]) // same property name twice + a.endNode() + let one = a.finish() + + let b = FDTBuilder() + b.beginNode("") + b.property("reg", cells: [1]) + b.property("other", cells: [2]) + b.endNode() + let two = b.finish() + // Reusing "reg" must not grow the strings block the way two distinct names do. + #expect(one.count < two.count) + } +} + +@Suite struct KernelImageTests { + private func writeImage(magic: UInt32, textOffset: UInt64, imageSize: UInt64, bytes: Int) throws -> String { + var data = [UInt8](repeating: 0, count: max(bytes, 64)) + func putLE64(_ value: UInt64, at offset: Int) { + for i in 0..<8 { data[offset + i] = UInt8((value >> (8 * i)) & 0xFF) } + } + func putLE32(_ value: UInt32, at offset: Int) { + for i in 0..<4 { data[offset + i] = UInt8((value >> (8 * i)) & 0xFF) } + } + putLE64(textOffset, at: 8) + putLE64(imageSize, at: 16) + putLE32(magic, at: 56) + let path = NSTemporaryDirectory() + "/dory-kernel-test-\(textOffset)-\(bytes).img" + try Data(data).write(to: URL(fileURLWithPath: path)) + return path + } + + @Test func parsesArm64ImageHeader() throws { + let path = try writeImage(magic: 0x644D_5241, textOffset: 0x8_0000, imageSize: 0x20_0000, bytes: 4096) + defer { try? FileManager.default.removeItem(atPath: path) } + let image = try KernelImage(contentsOf: path) + #expect(image.textOffset == 0x8_0000) + #expect(image.imageSize == 0x20_0000) // declared size wins when larger than the file + } + + @Test func rejectsBadMagic() throws { + let path = try writeImage(magic: 0xDEAD_BEEF, textOffset: 0, imageSize: 0, bytes: 4096) + defer { try? FileManager.default.removeItem(atPath: path) } + #expect(throws: VMError.self) { _ = try KernelImage(contentsOf: path) } + } +} + +@Suite struct DataAbortInfoTests { + @Test func decodesWriteOfA4ByteRegister() { + // ISV=1, SAS=0b10 (4 bytes), SSE=0, SRT=5, SF=1, WnR=1 + var syndrome: UInt64 = 0 + syndrome |= 1 << 24 // ISV + syndrome |= 0b10 << 22 // SAS -> width 4 + syndrome |= 5 << 16 // SRT + syndrome |= 1 << 15 // SF (64-bit reg) + syndrome |= 1 << 6 // WnR (write) + let info = DataAbortInfo(syndrome: syndrome) + #expect(info.isValid) + #expect(info.width == 4) + #expect(info.registerIndex == 5) + #expect(info.sixtyFourBit) + #expect(info.isWrite) + #expect(!info.signExtend) + } + + @Test func decodesSignExtendedByteRead() { + var syndrome: UInt64 = 0 + syndrome |= 1 << 24 // ISV + syndrome |= 0b00 << 22 // SAS -> width 1 + syndrome |= 1 << 21 // SSE + syndrome |= 31 << 16 // SRT = 31 (xzr) + let info = DataAbortInfo(syndrome: syndrome) + #expect(info.width == 1) + #expect(info.registerIndex == 31) + #expect(info.signExtend) + #expect(!info.isWrite) + } + + @Test func exceptionClassFromSyndrome() { + #expect(ExceptionClass(syndrome: UInt64(0x24) << 26) == .dataAbortLowerEL) + #expect(ExceptionClass(syndrome: UInt64(0x20) << 26) == .instructionAbortLowerEL) + #expect(ExceptionClass(syndrome: UInt64(0x16) << 26) == .hvc64) + #expect(ExceptionClass(syndrome: UInt64(0x17) << 26) == .smc64) + } +} + +@Suite struct MMIOBusTests { + private final class StubDevice: MMIODevice { + let baseAddress: UInt64 + let size: UInt64 + init(base: UInt64, size: UInt64) { self.baseAddress = base; self.size = size } + func read(offset: UInt64, width: Int) -> UInt64 { offset } + func write(offset: UInt64, value: UInt64, width: Int) {} + } + + @Test func routesByAddressAndComputesOffset() { + let bus = MMIOBus() + let uart = StubDevice(base: 0x0C00_0000, size: 0x1000) + let virtio = StubDevice(base: 0x0C10_0000, size: 0x200) + bus.attach(uart) + bus.attach(virtio) + + let hit = bus.device(for: 0x0C00_0018) + #expect(hit?.0 === uart) + #expect(hit?.1 == 0x18) + #expect(bus.device(for: 0x0C10_0004)?.0 === virtio) + #expect(bus.device(for: 0x0C10_0004)?.1 == 4) + #expect(bus.device(for: 0x0900_0000) == nil) // below any device + #expect(bus.device(for: 0x0C00_1000) == nil) // exactly past the UART window + } +} + +@Suite struct VirtioMMIOTransportTests { + private final class Backend: VirtioDeviceBackend, VirtioSharedMemoryRegionProvider { + let deviceID: UInt32 = 26 + let deviceFeatures: UInt64 = 0 + let queueCount = 1 + let configSpace: [UInt8] = [] + let sharedMemoryRegions: [VirtioSharedMemoryRegion] + + init(sharedMemoryRegions: [VirtioSharedMemoryRegion]) { + self.sharedMemoryRegions = sharedMemoryRegions + } + + func handleKick(queue: Int, transport: VirtioMMIOTransport) {} + } + + @Test func sharedMemoryRegistersExposeSelectedRegionAndMissingSentinel() throws { + let memory = try GuestMemory(guestBase: GuestLayout.ramBase, size: 0x20_000) + let backend = Backend(sharedMemoryRegions: [ + VirtioSharedMemoryRegion(id: 0, guestBase: 0x1_0000_0000, length: 0x2_0000), + VirtioSharedMemoryRegion(id: 3, guestBase: 0x2_0010_0000, length: 0x1_0000_0000), + ]) + let transport = VirtioMMIOTransport(baseAddress: GuestLayout.virtioBase, backend: backend, memory: memory) {} + + transport.write(offset: 0x0AC, value: 3, width: 4) + + #expect(transport.read(offset: 0x0B0, width: 4) == 0) + #expect(transport.read(offset: 0x0B4, width: 4) == 1) + #expect(transport.read(offset: 0x0B8, width: 4) == 0x0010_0000) + #expect(transport.read(offset: 0x0BC, width: 4) == 2) + + transport.write(offset: 0x0AC, value: 99, width: 4) + + #expect(transport.read(offset: 0x0B0, width: 4) == UInt64(UInt32.max)) + #expect(transport.read(offset: 0x0B4, width: 4) == UInt64(UInt32.max)) + } +} + +@Suite struct PL011Tests { + @Test func transmitsToSinkAndReportsReadyFlags() { + var out = [UInt8]() + let uart = PL011(baseAddress: 0x0C00_0000) { out.append($0) } + uart.write(offset: 0x00, value: UInt64(UInt8(ascii: "H")), width: 1) + uart.write(offset: 0x00, value: UInt64(UInt8(ascii: "i")), width: 1) + #expect(out == [UInt8(ascii: "H"), UInt8(ascii: "i")]) + #expect(uart.read(offset: 0x18, width: 2) == 0x90) // FR: TX empty | RX empty + #expect(uart.read(offset: 0xFE0, width: 4) == 0x11) // PeriphID0 + #expect(uart.read(offset: 0xFF0, width: 4) == 0x0D) // PCellID0 + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift new file mode 100644 index 0000000..085534a --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/DirectIPBridgeTests.swift @@ -0,0 +1,70 @@ +import DoryHV +import Foundation +import Testing + +struct DirectIPBridgeTests { + @Test func classifiesUtunFramesForRoutedContainerSubnet() throws { + let bridge = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "10.0.0.10", destination: "192.168.215.42", protocolNumber: 1) + + let decision = bridge.classifyOutboundUtunFrame(DirectIPPacketBridge.utunIPv4Header + packet) + + #expect(decision == .injectToGvproxy(packet: packet, destination: DirectIPv4Address("192.168.215.42")!)) + } + + @Test func ignoresMalformedAndForeignUtunFrames() throws { + let bridge = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let foreign = ipv4Packet(source: "10.0.0.10", destination: "10.20.30.40", protocolNumber: 6) + + #expect(bridge.classifyOutboundUtunFrame(Data([0, 0, 0, 30]) + foreign) == .ignore(reason: "not an IPv4 utun frame")) + #expect(bridge.classifyOutboundUtunFrame(DirectIPPacketBridge.utunIPv4Header + Data([0x45, 0])) == .ignore(reason: "not an IPv4 utun frame")) + #expect(bridge.classifyOutboundUtunFrame(DirectIPPacketBridge.utunIPv4Header + foreign) == .ignore(reason: "destination outside routed subnet")) + } + + @Test func framesIPv4PacketsForGvproxyAndExtractsReplies() throws { + let bridge = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.2") + let packet = ipv4Packet(source: "10.0.0.10", destination: "192.168.215.42", protocolNumber: 1) + let frame = try #require(bridge.ethernetFrameForGvproxy(packet)) + + #expect(Array(frame.prefix(6)) == DirectIPPacketBridge.guestMAC) + #expect(Array(frame.dropFirst(6).prefix(6)) == DirectIPPacketBridge.bridgeMAC) + #expect(Array(frame.dropFirst(12).prefix(2)) == [0x08, 0x00]) + #expect(bridge.ipv4PacketFromGvproxyFrame(frame) == packet) + #expect(bridge.wrapInboundPacketForUtun(packet) == DirectIPPacketBridge.utunIPv4Header + packet) + } + + @Test func validatesBridgeConfigurationInputs() throws { + #expect(throws: DirectIPBridgeError.invalidCIDR("192.168.215.0/33")) { + _ = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/33", gateway: "192.168.127.2") + } + #expect(throws: DirectIPBridgeError.invalidIPv4("192.168.127.999")) { + _ = try DirectIPPacketBridge(subnetCIDR: "192.168.215.0/24", gateway: "192.168.127.999") + } + } + + private func ipv4Packet(source: String, destination: String, protocolNumber: UInt8) -> Data { + let sourceAddress = DirectIPv4Address(source)!.rawValue + let destinationAddress = DirectIPv4Address(destination)!.rawValue + var packet = Data([ + 0x45, 0x00, + 0x00, 0x1c, + 0x12, 0x34, + 0x00, 0x00, + 0x40, protocolNumber, + 0x00, 0x00, + ]) + packet.append(contentsOf: bytes(sourceAddress)) + packet.append(contentsOf: bytes(destinationAddress)) + packet.append(contentsOf: [0x08, 0x00, 0x00, 0x00, 0xde, 0xad, 0xbe, 0xef]) + return packet + } + + private func bytes(_ value: UInt32) -> [UInt8] { + [ + UInt8((value >> 24) & 0xff), + UInt8((value >> 16) & 0xff), + UInt8((value >> 8) & 0xff), + UInt8(value & 0xff), + ] + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseProtocolTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseProtocolTests.swift new file mode 100644 index 0000000..ad64428 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseProtocolTests.swift @@ -0,0 +1,149 @@ +import Testing +@testable import DoryHV + +struct FuseProtocolTests { + @Test func inHeaderRoundTripsLittleEndianFields() throws { + let header = FuseInHeader( + length: 56, + opcode: FuseOpcode.initOp.rawValue, + unique: 0x0102_0304_0506_0708, + nodeID: 1, + uid: 501, + gid: 20, + pid: 1234, + totalExtlen: 16, + padding: 0 + ) + + let bytes = FuseProtocol.encodeInHeader(header) + let decoded = try FuseProtocol.decodeInHeader(bytes) + + #expect(bytes.count == FuseInHeader.byteCount) + #expect(bytes[0..<4].elementsEqual([56, 0, 0, 0])) + #expect(bytes[4..<8].elementsEqual([26, 0, 0, 0])) + #expect(bytes[8..<16].elementsEqual([8, 7, 6, 5, 4, 3, 2, 1])) + #expect(decoded == header) + } + + @Test func initNegotiationCapsMinorAndPreservesUnique() throws { + let header = FuseInHeader( + length: UInt32(FuseInHeader.byteCount + FuseInitIn.byteCount), + opcode: FuseOpcode.initOp.rawValue, + unique: 0xabc, + nodeID: 1, + uid: 0, + gid: 0, + pid: 42 + ) + let request = FuseInitIn(major: 7, minor: 45, maxReadahead: 131_072, flags: FuseInitFlag.asyncRead.rawValue) + + let response = FuseProtocol.negotiateInit(header: header, request: request) + let outHeader = try FuseProtocol.decodeOutHeader(Array(response.prefix(FuseOutHeader.byteCount))) + let payload = Array(response.dropFirst(FuseOutHeader.byteCount)) + + #expect(outHeader == FuseOutHeader(length: UInt32(FuseOutHeader.byteCount + FuseInitOut.byteCount), error: 0, unique: 0xabc)) + #expect(payload.count == FuseInitOut.byteCount) + #expect(payload[0..<4].elementsEqual([7, 0, 0, 0])) + #expect(payload[4..<8].elementsEqual([38, 0, 0, 0])) + #expect(payload[8..<12].elementsEqual([0, 0, 2, 0])) + } + + @Test func initNegotiationCanAdvertiseDaxMapAlignment() throws { + let header = FuseInHeader( + length: UInt32(FuseInHeader.byteCount + FuseInitIn.byteCount), + opcode: FuseOpcode.initOp.rawValue, + unique: 0xdef, + nodeID: 1, + uid: 0, + gid: 0, + pid: 42 + ) + let request = FuseInitIn(major: 7, minor: 38, maxReadahead: 0, flags: 0) + + let response = FuseProtocol.negotiateInit(header: header, request: request, daxMapAlignmentLog2: 12) + let payload = Array(response.dropFirst(FuseOutHeader.byteCount)) + + #expect(payload.leUInt32(at: 12) & FuseInitFlag.mapAlignment.rawValue == FuseInitFlag.mapAlignment.rawValue) + #expect(payload.leUInt16(at: 30) == 12) + } + + @Test func initNegotiationRejectsMinorsBelowVirtiofsdFloor() throws { + let header = FuseInHeader( + length: UInt32(FuseInHeader.byteCount + FuseInitIn.byteCount), + opcode: FuseOpcode.initOp.rawValue, + unique: 99, + nodeID: 1, + uid: 0, + gid: 0, + pid: 42 + ) + let request = FuseInitIn(major: 7, minor: 26, maxReadahead: 0, flags: 0) + + let response = FuseProtocol.negotiateInit(header: header, request: request) + let outHeader = try FuseProtocol.decodeOutHeader(response) + + #expect(outHeader.length == UInt32(FuseOutHeader.byteCount)) + #expect(outHeader.error == -FuseProtocol.eproto) + #expect(outHeader.unique == 99) + } + + @Test func initInRejectsShortFrames() { + #expect(throws: FuseProtocolError.shortFrame) { + _ = try FuseProtocol.decodeInitIn([0, 1, 2]) + } + } + + @Test func setupMappingRoundTripsLittleEndianFields() throws { + let request = FuseSetupMappingIn( + fileHandle: 0x0102_0304_0506_0708, + fileOffset: 0x1000, + length: 0x4000, + flags: 0x55, + memoryOffset: 0x8000 + ) + + let bytes = FuseProtocol.encodeSetupMappingIn(request) + let decoded = try FuseProtocol.decodeSetupMappingIn(bytes) + + #expect(bytes.count == FuseSetupMappingIn.byteCount) + #expect(bytes[0..<8].elementsEqual([8, 7, 6, 5, 4, 3, 2, 1])) + #expect(bytes[8..<16].elementsEqual([0, 0x10, 0, 0, 0, 0, 0, 0])) + #expect(decoded == request) + } + + @Test func removeMappingRoundTripsMultipleMappings() throws { + let request = FuseRemoveMappingIn(mappings: [ + FuseRemoveMappingOne(memoryOffset: 0x4000, length: 0x1000), + FuseRemoveMappingOne(memoryOffset: 0x8000, length: 0x2000), + ]) + + let bytes = FuseProtocol.encodeRemoveMappingIn(request) + let decoded = try FuseProtocol.decodeRemoveMappingIn(bytes) + + #expect(bytes.count == FuseRemoveMappingIn.headerByteCount + 2 * FuseRemoveMappingIn.oneByteCount) + #expect(bytes[0..<4].elementsEqual([2, 0, 0, 0])) + #expect(decoded == request) + } + + @Test func mappingPayloadsRejectShortFrames() { + #expect(throws: FuseProtocolError.shortFrame) { + _ = try FuseProtocol.decodeSetupMappingIn([0, 1, 2]) + } + #expect(throws: FuseProtocolError.shortFrame) { + _ = try FuseProtocol.decodeRemoveMappingIn([1, 0, 0, 0, 0, 0, 0, 0]) + } + } +} + +private extension Array where Element == UInt8 { + func leUInt16(at offset: Int) -> UInt16 { + UInt16(self[offset]) | UInt16(self[offset + 1]) << 8 + } + + func leUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift new file mode 100644 index 0000000..5020d50 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/FuseServerTests.swift @@ -0,0 +1,387 @@ +import Darwin +import Foundation +import Testing +@testable import DoryHV + +struct FuseServerTests { + @Test func lookupGetattrOpenReadAndReleaseFlow() throws { + let root = try TestFuseServerRoot() + try root.write("hello dory", to: "hello.txt") + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + + let lookup = server.handle(request: request(unique: 10, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("hello.txt\0".utf8))) + let lookupHeader = try FuseProtocol.decodeOutHeader(lookup) + let lookupPayload = payload(from: lookup) + let nodeID = lookupPayload.leUInt64(at: 0) + + #expect(lookupHeader.error == 0) + #expect(lookupHeader.unique == 10) + #expect(lookupHeader.length == UInt32(FuseOutHeader.byteCount + 128)) + #expect(nodeID != HostFS.rootNodeID) + #expect(lookupPayload.leUInt64(at: 40) == nodeID) + #expect(lookupPayload.leUInt64(at: 48) == 10) + #expect(lookupPayload.leUInt32(at: 100) & UInt32(S_IFMT) == UInt32(S_IFREG)) + + let getattr = server.handle(request: request(unique: 11, opcode: .getattr, nodeID: nodeID)) + let getattrHeader = try FuseProtocol.decodeOutHeader(getattr) + let getattrPayload = payload(from: getattr) + + #expect(getattrHeader.error == 0) + #expect(getattrPayload.count == 104) + #expect(getattrPayload.leUInt64(at: 16) == nodeID) + #expect(getattrPayload.leUInt64(at: 24) == 10) + + let open = server.handle(request: request(unique: 12, opcode: .open, nodeID: nodeID, payload: bytes(UInt32(O_RDONLY)) + bytes(UInt32(0)))) + let openHeader = try FuseProtocol.decodeOutHeader(open) + let handle = payload(from: open).leUInt64(at: 0) + + #expect(openHeader.error == 0) + #expect(handle > 0) + + let readIn = bytes(handle) + bytes(UInt64(6)) + bytes(UInt32(4)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) + let read = server.handle(request: request(unique: 13, opcode: .read, nodeID: nodeID, payload: readIn)) + + #expect(try FuseProtocol.decodeOutHeader(read).error == 0) + #expect(String(decoding: payload(from: read), as: UTF8.self) == "dory") + + let release = server.handle(request: request(unique: 14, opcode: .release, nodeID: nodeID, payload: bytes(handle) + bytes(UInt32(0)) + bytes(UInt32(0)) + bytes(UInt64(0)))) + + #expect(try FuseProtocol.decodeOutHeader(release).error == 0) + + let readAfterRelease = server.handle(request: request(unique: 15, opcode: .read, nodeID: nodeID, payload: readIn)) + + #expect(try FuseProtocol.decodeOutHeader(readAfterRelease).error == -EBADF) + } + + @Test func zeroCopyReadMatchesArrayPath() throws { + let root = try TestFuseServerRoot() + try root.write("hello dory world", to: "z.txt") + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let nodeID = payload(from: server.handle(request: request(unique: 1, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("z.txt\0".utf8)))).leUInt64(at: 0) + let handle = payload(from: server.handle(request: request(unique: 2, opcode: .open, nodeID: nodeID, payload: bytes(UInt32(O_RDONLY)) + bytes(UInt32(0))))).leUInt64(at: 0) + + let readIn = bytes(handle) + bytes(UInt64(6)) + bytes(UInt32(4)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) + let req = request(unique: 3, opcode: .read, nodeID: nodeID, payload: readIn) + + let arrayPath = server.handle(request: req) + + let header = try FuseProtocol.decodeInHeader(req) + let readPayload = Array(req[FuseInHeader.byteCount.. Int in + let segment = VirtqueueSegment(pointer: buffer.baseAddress!, length: buffer.count, isDeviceWritable: true) + return server.writeReadResponse(header: header, payload: readPayload, writable: [segment]) + } + + #expect(Array(dest[0.. 128 + 24) + #expect(data.leUInt32(at: 128 + 16) == 5) + #expect(String(decoding: data[(128 + 24)..<(128 + 29)], as: UTF8.self) == "a.txt") + let firstLength = alignedDirentPlusLength(nameLength: 5) + #expect(data.leUInt32(at: firstLength + 128 + 16) == 5) + #expect(String(decoding: data[(firstLength + 128 + 24)..<(firstLength + 128 + 29)], as: UTF8.self) == "b.txt") + } + + @Test func createWriteFsyncRenameAndUnlinkMutateHostFilesystem() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let createIn = bytes(UInt32(O_CREAT | O_RDWR)) + bytes(UInt32(0o644)) + bytes(UInt32(0)) + bytes(UInt32(0)) + Array("draft.txt\0".utf8) + + let create = server.handle(request: request(unique: 40, opcode: .create, nodeID: HostFS.rootNodeID, payload: createIn)) + let createPayload = payload(from: create) + let nodeID = createPayload.leUInt64(at: 0) + let handle = createPayload.leUInt64(at: 128) + + #expect(try FuseProtocol.decodeOutHeader(create).error == 0) + #expect(nodeID != 0) + #expect(handle != 0) + + let writeIn = bytes(handle) + bytes(UInt64(0)) + bytes(UInt32(11)) + bytes(UInt32(0)) + bytes(UInt64(0)) + bytes(UInt32(0)) + bytes(UInt32(0)) + Array("hello world".utf8) + let write = server.handle(request: request(unique: 41, opcode: .write, nodeID: nodeID, payload: writeIn)) + + #expect(try FuseProtocol.decodeOutHeader(write).error == 0) + #expect(payload(from: write).leUInt32(at: 0) == 11) + + let fsync = server.handle(request: request(unique: 42, opcode: .fsync, nodeID: nodeID, payload: bytes(handle) + bytes(UInt32(0)) + bytes(UInt32(0)))) + #expect(try FuseProtocol.decodeOutHeader(fsync).error == 0) + + let release = server.handle(request: request(unique: 43, opcode: .release, nodeID: nodeID, payload: bytes(handle) + bytes(UInt32(0)) + bytes(UInt32(0)) + bytes(UInt64(0)))) + #expect(try FuseProtocol.decodeOutHeader(release).error == 0) + #expect(try String(contentsOf: root.url.appendingPathComponent("draft.txt"), encoding: .utf8) == "hello world") + + let renameIn = bytes(UInt64(HostFS.rootNodeID)) + Array("draft.txt\0final.txt\0".utf8) + let rename = server.handle(request: request(unique: 44, opcode: .rename, nodeID: HostFS.rootNodeID, payload: renameIn)) + + #expect(try FuseProtocol.decodeOutHeader(rename).error == 0) + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("final.txt").path)) + + let unlink = server.handle(request: request(unique: 45, opcode: .unlink, nodeID: HostFS.rootNodeID, payload: Array("final.txt\0".utf8))) + + #expect(try FuseProtocol.decodeOutHeader(unlink).error == 0) + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("final.txt").path)) + } + + @Test func setattrSameModeSucceedsForFSEventsRelayWithoutChangingMode() throws { + let root = try TestFuseServerRoot() + try root.write("payload", to: "watched.txt") + try FileManager.default.setAttributes([.posixPermissions: 0o640], ofItemAtPath: root.url.appendingPathComponent("watched.txt").path) + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let lookup = server.handle(request: request(unique: 52, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("watched.txt\0".utf8))) + let nodeID = payload(from: lookup).leUInt64(at: 0) + + let sameMode = server.handle(request: request(unique: 53, opcode: .setattr, nodeID: nodeID, payload: setattrIn(mode: 0o640))) + + #expect(try FuseProtocol.decodeOutHeader(sameMode).error == 0) + #expect(payload(from: sameMode).leUInt32(at: 76) & 0o7777 == 0o640) + #expect(try FileManager.default.attributesOfItem(atPath: root.url.appendingPathComponent("watched.txt").path)[.posixPermissions] as? Int == 0o640) + + let differentMode = server.handle(request: request(unique: 54, opcode: .setattr, nodeID: nodeID, payload: setattrIn(mode: 0o600))) + + #expect(try FuseProtocol.decodeOutHeader(differentMode).error == -EOPNOTSUPP) + } + + @Test func setattrSizeTruncatesAndGrowsHostFile() throws { + let root = try TestFuseServerRoot() + try root.write("0123456789ABCDEF", to: "resize.txt") + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let file = root.url.appendingPathComponent("resize.txt") + let lookup = server.handle(request: request(unique: 60, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("resize.txt\0".utf8))) + let nodeID = payload(from: lookup).leUInt64(at: 0) + + let shrink = server.handle(request: request(unique: 61, opcode: .setattr, nodeID: nodeID, payload: setattrSize(5))) + #expect(try FuseProtocol.decodeOutHeader(shrink).error == 0) + #expect(payload(from: shrink).leUInt64(at: 24) == 5) + #expect(try Data(contentsOf: file) == Data("01234".utf8)) + + let open = server.handle(request: request(unique: 62, opcode: .open, nodeID: nodeID, payload: bytes(UInt32(bitPattern: O_RDWR)) + bytes(UInt32(0)))) + let handle = payload(from: open).leUInt64(at: 0) + let grow = server.handle(request: request(unique: 63, opcode: .setattr, nodeID: nodeID, payload: setattrSize(10, fileHandle: handle))) + #expect(try FuseProtocol.decodeOutHeader(grow).error == 0) + #expect(payload(from: grow).leUInt64(at: 24) == 10) + let grown = try Data(contentsOf: file) + #expect(grown.count == 10) + #expect(grown.prefix(5) == Data("01234".utf8)) + #expect(Array(grown.suffix(5)) == [0, 0, 0, 0, 0]) + } + + @Test func setattrSizeOnReadOnlyShareFails() throws { + let root = try TestFuseServerRoot() + try root.write("keepme", to: "ro.txt") + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path, readOnly: true)) + let lookup = server.handle(request: request(unique: 70, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("ro.txt\0".utf8))) + let nodeID = payload(from: lookup).leUInt64(at: 0) + + let truncate = server.handle(request: request(unique: 71, opcode: .setattr, nodeID: nodeID, payload: setattrSize(0))) + + #expect(try FuseProtocol.decodeOutHeader(truncate).error != 0) + #expect(try Data(contentsOf: root.url.appendingPathComponent("ro.txt")) == Data("keepme".utf8)) + } + + @Test func mkdirAndRmdirMutateHostFilesystem() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let mkdirIn = bytes(UInt32(0o755)) + bytes(UInt32(0)) + Array("nested\0".utf8) + + let mkdir = server.handle(request: request(unique: 50, opcode: .mkdir, nodeID: HostFS.rootNodeID, payload: mkdirIn)) + + #expect(try FuseProtocol.decodeOutHeader(mkdir).error == 0) + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("nested").path)) + + let rmdir = server.handle(request: request(unique: 51, opcode: .rmdir, nodeID: HostFS.rootNodeID, payload: Array("nested\0".utf8))) + + #expect(try FuseProtocol.decodeOutHeader(rmdir).error == 0) + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("nested").path)) + } + + @Test func statfsAndErrorResponsesUseFuseOutHeaders() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + + let statfs = server.handle(request: request(unique: 30, opcode: .statfs, nodeID: HostFS.rootNodeID)) + let missing = server.handle(request: request(unique: 31, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("missing\0".utf8))) + let unsupported = server.handle(request: request(unique: 32, opcodeRaw: 9_999, nodeID: HostFS.rootNodeID)) + + #expect(try FuseProtocol.decodeOutHeader(statfs).length == UInt32(FuseOutHeader.byteCount + 80)) + #expect(payload(from: statfs).leUInt64(at: 0) > 0) + #expect(try FuseProtocol.decodeOutHeader(missing).error == -ENOENT) + #expect(try FuseProtocol.decodeOutHeader(unsupported).error == -ENOSYS) + } + + @Test func daxSetupAndRemoveMappingRequireConfiguredWindowAndOpenHandle() throws { + let root = try TestFuseServerRoot() + try root.write("hello dory", to: "hello.txt") + let hostFS = try HostFS(rootPath: root.url.path) + let server = try FuseServer( + hostFS: hostFS, + daxWindow: DaxWindow(guestBase: 0x1_0000_0000, length: 0x20_000) + ) + + let lookup = server.handle(request: request(unique: 60, opcode: .lookup, nodeID: HostFS.rootNodeID, payload: Array("hello.txt\0".utf8))) + let nodeID = payload(from: lookup).leUInt64(at: 0) + + let missingHandleSetup = server.handle(request: request( + unique: 61, + opcode: .setupmapping, + nodeID: nodeID, + payload: FuseProtocol.encodeSetupMappingIn(FuseSetupMappingIn(fileHandle: 999, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0)) + )) + #expect(try FuseProtocol.decodeOutHeader(missingHandleSetup).error == -EBADF) + + let open = server.handle(request: request(unique: 62, opcode: .open, nodeID: nodeID, payload: bytes(UInt32(O_RDONLY)) + bytes(UInt32(0)))) + let handle = payload(from: open).leUInt64(at: 0) + + let setup = server.handle(request: request( + unique: 63, + opcode: .setupmapping, + nodeID: nodeID, + payload: FuseProtocol.encodeSetupMappingIn(FuseSetupMappingIn(fileHandle: handle, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0x4000)) + )) + #expect(try FuseProtocol.decodeOutHeader(setup).error == 0) + + let overlap = server.handle(request: request( + unique: 64, + opcode: .setupmapping, + nodeID: nodeID, + payload: FuseProtocol.encodeSetupMappingIn(FuseSetupMappingIn(fileHandle: handle, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0x4000)) + )) + #expect(try FuseProtocol.decodeOutHeader(overlap).error == -EBUSY) + + let remove = server.handle(request: request( + unique: 65, + opcode: .removemapping, + nodeID: nodeID, + payload: FuseProtocol.encodeRemoveMappingIn(FuseRemoveMappingIn(mappings: [ + FuseRemoveMappingOne(memoryOffset: 0x4000, length: 0x4000), + ])) + )) + #expect(try FuseProtocol.decodeOutHeader(remove).error == 0) + } + + @Test func daxMappingOpcodesReturnENOSYSWithoutWindow() throws { + let root = try TestFuseServerRoot() + let server = try FuseServer(hostFS: HostFS(rootPath: root.url.path)) + let setup = server.handle(request: request( + unique: 70, + opcode: .setupmapping, + nodeID: HostFS.rootNodeID, + payload: FuseProtocol.encodeSetupMappingIn(FuseSetupMappingIn(fileHandle: 1, fileOffset: 0, length: 0x4000, flags: 0, memoryOffset: 0)) + )) + let remove = server.handle(request: request( + unique: 71, + opcode: .removemapping, + nodeID: HostFS.rootNodeID, + payload: FuseProtocol.encodeRemoveMappingIn(FuseRemoveMappingIn(mappings: [ + FuseRemoveMappingOne(memoryOffset: 0, length: 0x4000), + ])) + )) + + #expect(try FuseProtocol.decodeOutHeader(setup).error == -ENOSYS) + #expect(try FuseProtocol.decodeOutHeader(remove).error == -ENOSYS) + } +} + +private func request(unique: UInt64, opcode: FuseOpcode, nodeID: UInt64, payload: [UInt8] = []) -> [UInt8] { + request(unique: unique, opcodeRaw: opcode.rawValue, nodeID: nodeID, payload: payload) +} + +private func request(unique: UInt64, opcodeRaw: UInt32, nodeID: UInt64, payload: [UInt8] = []) -> [UInt8] { + FuseProtocol.encodeInHeader(FuseInHeader( + length: UInt32(FuseInHeader.byteCount + payload.count), + opcode: opcodeRaw, + unique: unique, + nodeID: nodeID, + uid: 1000, + gid: 1000, + pid: 42 + )) + payload +} + +private func payload(from response: [UInt8]) -> [UInt8] { + Array(response.dropFirst(FuseOutHeader.byteCount)) +} + +private func alignedDirentPlusLength(nameLength: Int) -> Int { + var length = 128 + 24 + nameLength + while length % 8 != 0 { length += 1 } + return length +} + +private func bytes(_ value: UInt32) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func bytes(_ value: UInt64) -> [UInt8] { + var value = value.littleEndian + return withUnsafeBytes(of: &value) { Array($0) } +} + +private func setattrIn(mode: UInt32) -> [UInt8] { + var data = [UInt8](repeating: 0, count: 88) + replaceLE(UInt32(1), at: 0, in: &data) + replaceLE(mode, at: 68, in: &data) + return data +} + +private func setattrSize(_ size: UInt64, fileHandle: UInt64? = nil) -> [UInt8] { + var data = [UInt8](repeating: 0, count: 88) + var valid = FuseSetattrValid.size.rawValue + if let fileHandle { + valid |= FuseSetattrValid.fileHandle.rawValue + data.replaceSubrange(8..<16, with: bytes(fileHandle)) + } + replaceLE(valid, at: 0, in: &data) + data.replaceSubrange(16..<24, with: bytes(size)) + return data +} + +private func replaceLE(_ value: UInt32, at offset: Int, in data: inout [UInt8]) { + let bytes = bytes(value) + data.replaceSubrange(offset..<(offset + bytes.count), with: bytes) +} + +private extension Array where Element == UInt8 { + func leUInt32(at offset: Int) -> UInt32 { + UInt32(self[offset]) + | UInt32(self[offset + 1]) << 8 + | UInt32(self[offset + 2]) << 16 + | UInt32(self[offset + 3]) << 24 + } + + func leUInt64(at offset: Int) -> UInt64 { + UInt64(leUInt32(at: offset)) | UInt64(leUInt32(at: offset + 4)) << 32 + } +} + +private final class TestFuseServerRoot { + let url: URL + + init() throws { + url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-fuseserver-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + func write(_ text: String, to relativePath: String) throws { + try text.write(to: url.appendingPathComponent(relativePath), atomically: true, encoding: .utf8) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestMemoryTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestMemoryTests.swift new file mode 100644 index 0000000..c4b9ac9 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/GuestMemoryTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import DoryHV + +@Suite struct GuestMemoryTests { + @Test func boundsCheckedReadWrite() throws { + let memory = try GuestMemory(guestBase: 0x8000_0000, size: 32 * 16384) + try memory.write(UInt64(0xDEAD_BEEF_CAFE_F00D), at: 0x8000_0100) + #expect(try memory.read(UInt64.self, at: 0x8000_0100) == 0xDEAD_BEEF_CAFE_F00D) + #expect(memory.contains(0x8000_0000, count: 32 * 16384)) + #expect(!memory.contains(0x8000_0000, count: 32 * 16384 + 1)) + #expect(!memory.contains(0x7FFF_FFFF, count: 1)) + #expect(throws: VMError.self) { + _ = try memory.read(UInt32.self, at: 0x8008_0000 - 2) + } + } + + @Test func rejectsUnalignedSize() { + #expect(throws: VMError.self) { + _ = try GuestMemory(guestBase: 0x8000_0000, size: 12345) + } + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSEventRelayTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSEventRelayTests.swift new file mode 100644 index 0000000..40dcf3e --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSEventRelayTests.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing +@testable import DoryHV + +struct HostFSEventRelayTests { + @Test func mapsHostPathsIntoGuestSharePaths() async throws { + let batcher = FSEventBatcher( + shares: [ + HostFSEventShare(hostRoot: "/Users/me/Project", guestRoot: "/mnt/dory/src"), + HostFSEventShare(hostRoot: "/Users/me/Cache", guestRoot: "/mnt/dory/cache"), + ], + send: { _ in } + ) + + #expect(batcher.mapHostPathToGuest("/Users/me/Project") == "/mnt/dory/src") + #expect(batcher.mapHostPathToGuest("/Users/me/Project/Sources/App.swift") == "/mnt/dory/src/Sources/App.swift") + #expect(batcher.mapHostPathToGuest("/Users/me/Cache/pkg") == "/mnt/dory/cache/pkg") + #expect(batcher.mapHostPathToGuest("/Users/me/Other/file") == nil) + } + + @Test func coalescesAndSortsDuplicatePathsPerFlush() async throws { + let sink = BatchSink() + let batcher = FSEventBatcher( + shares: [HostFSEventShare(hostRoot: "/host", guestRoot: "/guest")], + send: { paths in await sink.append(paths) } + ) + + batcher.enqueue(hostPaths: ["/host/b.txt", "/host/a.txt", "/host/b.txt", "/outside/nope"]) + await batcher.flushNow() + await batcher.flushNow() + + #expect(await sink.batches == [["/guest/a.txt", "/guest/b.txt"]]) + } + + @Test func agentChannelSendsFSEventBatchMethod() async throws { + let transport = StubAgentTransport(responsePayload: #"{"id":1,"result":{"touched":2}}"#) + let channel = AgentChannel(transport: transport) + + let result = try await channel.sendFSEventBatch(paths: ["/mnt/dory/src/a", "/mnt/dory/src/b"]) + let request = try transport.decodedRequest() + + #expect(result.touched == 2) + #expect(request.method == "fsevents.batch") + #expect(request.params.paths == ["/mnt/dory/src/a", "/mnt/dory/src/b"]) + } +} + +private actor BatchSink { + private(set) var batches: [[String]] = [] + + func append(_ paths: [String]) { + batches.append(paths) + } +} + +private struct CapturedFSEventRequest: Decodable { + var id: Int + var method: String + var params: FSEventBatchParams +} + +private final class StubAgentTransport: AgentByteTransport { + private var response: [UInt8] + private(set) var written = [UInt8]() + + init(responsePayload: String) { + self.response = (try? AgentFrameCodec.encode(Array(responsePayload.utf8))) ?? [] + } + + func readExact(_ count: Int) async throws -> [UInt8] { + let bytes = Array(response.prefix(count)) + response.removeFirst(min(count, response.count)) + return bytes + } + + func writeAll(_ bytes: [UInt8]) async throws { + written.append(contentsOf: bytes) + } + + func decodedRequest() throws -> CapturedFSEventRequest { + let length = try AgentFrameCodec.decodeLength(Array(written.prefix(4))) + let payload = Data(written.dropFirst(4).prefix(length)) + return try JSONDecoder().decode(CapturedFSEventRequest.self, from: payload) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift new file mode 100644 index 0000000..1c98dfe --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostFSTests.swift @@ -0,0 +1,257 @@ +import Darwin +import Foundation +import Testing +@testable import DoryHV + +struct HostFSTests { + @Test func rootGetattrReturnsDirectoryAttributes() throws { + let root = try TestHostFSRoot() + let fs = try HostFS(rootPath: root.url.path) + + let attrs = try fs.getattr(nodeID: HostFS.rootNodeID) + + #expect(attrs.nodeID == HostFS.rootNodeID) + #expect(attrs.isDirectory) + #expect(attrs.uid == 1000) + #expect(attrs.gid == 1000) + } + + @Test func lookupGetattrAndReadSquashIdentity() throws { + let root = try TestHostFSRoot() + try root.write("hello dory", to: "hello.txt") + let fs = try HostFS(rootPath: root.url.path) + + let entry = try fs.lookup(parent: HostFS.rootNodeID, name: "hello.txt") + let attrs = try fs.getattr(nodeID: entry.nodeID) + let handle = try fs.openRead(nodeID: entry.nodeID) + defer { fs.close(handle: handle) } + + #expect(entry.name == "hello.txt") + #expect(attrs.isRegularFile) + #expect(attrs.size == 10) + #expect(attrs.uid == 1000) + #expect(attrs.gid == 1000) + #expect(String(decoding: try fs.read(handle: handle, offset: 6, count: 4), as: UTF8.self) == "dory") + } + + @Test func readdirplusReturnsSortedEntriesWithAttributes() throws { + let root = try TestHostFSRoot() + try root.write("b", to: "b.txt") + try root.write("a", to: "a.txt") + try FileManager.default.createDirectory(at: root.url.appendingPathComponent("dir"), withIntermediateDirectories: false) + let fs = try HostFS(rootPath: root.url.path) + + let entries = try fs.readdirplus(nodeID: HostFS.rootNodeID) + + #expect(entries.map(\.name) == ["a.txt", "b.txt", "dir"]) + #expect(entries[0].attributes.isRegularFile) + #expect(entries[2].attributes.isDirectory) + } + + @Test func hiddenNamesAreInvisibleToLookupReaddirAndNested() throws { + let root = try TestHostFSRoot() + try FileManager.default.createDirectory(at: root.url.appendingPathComponent(".ssh"), withIntermediateDirectories: false) + try root.write("PRIVATE KEY", to: ".ssh/id_rsa") + try FileManager.default.createDirectory(at: root.url.appendingPathComponent("project"), withIntermediateDirectories: false) + try root.write("code", to: "project/main.swift") + try FileManager.default.createDirectory(at: root.url.appendingPathComponent("project/.ssh"), withIntermediateDirectories: false) + try root.write("nested secret", to: "project/.ssh/id_rsa") + let fs = try HostFS(rootPath: root.url.path, hiddenNames: [".ssh"]) + + // A hidden name is not listed and cannot be looked up (so no node id → no read/open path). + #expect(try fs.readdirplus(nodeID: HostFS.rootNodeID).map(\.name) == ["project"]) + #expect(throws: HostFSError.self) { _ = try fs.lookup(parent: HostFS.rootNodeID, name: ".ssh") } + + // Hiding is by name at any depth: the same name nested under an allowed dir is also hidden. + let project = try fs.lookup(parent: HostFS.rootNodeID, name: "project") + #expect(try fs.readdirplus(nodeID: project.nodeID).map(\.name) == ["main.swift"]) + #expect(throws: HostFSError.self) { _ = try fs.lookup(parent: project.nodeID, name: ".ssh") } + + // Non-hidden siblings still resolve normally. + let file = try fs.lookup(parent: project.nodeID, name: "main.swift") + #expect(file.attributes.isRegularFile) + } + + @Test func hiddenNamesRejectMutationsBeforeHostChanges() throws { + let root = try TestHostFSRoot() + try root.write("keep", to: ".env") + try root.write("secret", to: ".secret") + try root.write("visible", to: "visible.txt") + try FileManager.default.createDirectory(at: root.url.appendingPathComponent(".cache"), withIntermediateDirectories: false) + let fs = try HostFS(rootPath: root.url.path, hiddenNames: [".cache", ".env", ".secret", ".ssh", ".target"]) + + #expect(throws: HostFSError.notFound(".env")) { + _ = try fs.createFile(parent: HostFS.rootNodeID, name: ".env") + } + #expect(try String(contentsOf: root.url.appendingPathComponent(".env"), encoding: .utf8) == "keep") + + #expect(throws: HostFSError.notFound(".ssh")) { + _ = try fs.mkdir(parent: HostFS.rootNodeID, name: ".ssh") + } + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent(".ssh").path)) + + #expect(throws: HostFSError.notFound(".env")) { + try fs.unlink(parent: HostFS.rootNodeID, name: ".env") + } + #expect(try String(contentsOf: root.url.appendingPathComponent(".env"), encoding: .utf8) == "keep") + + #expect(throws: HostFSError.notFound(".cache")) { + try fs.rmdir(parent: HostFS.rootNodeID, name: ".cache") + } + var isDirectory = ObjCBool(false) + let cacheExists = FileManager.default.fileExists(atPath: root.url.appendingPathComponent(".cache").path, isDirectory: &isDirectory) + #expect(cacheExists) + #expect(isDirectory.boolValue) + + #expect(throws: HostFSError.notFound(".target")) { + _ = try fs.rename(parent: HostFS.rootNodeID, name: "visible.txt", newParent: HostFS.rootNodeID, newName: ".target") + } + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("visible.txt").path)) + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent(".target").path)) + + #expect(throws: HostFSError.notFound(".secret")) { + _ = try fs.rename(parent: HostFS.rootNodeID, name: ".secret", newParent: HostFS.rootNodeID, newName: "revealed.txt") + } + #expect(try String(contentsOf: root.url.appendingPathComponent(".secret"), encoding: .utf8) == "secret") + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("revealed.txt").path)) + } + + @Test func statfsReturnsHostFilesystemShape() throws { + let root = try TestHostFSRoot() + let fs = try HostFS(rootPath: root.url.path) + + let stat = try fs.statfs() + + #expect(stat.blockSize > 0) + #expect(stat.blocks > 0) + #expect(stat.nameMax > 0) + } + + @Test func lookupRejectsTraversalAndNestedNames() throws { + let root = try TestHostFSRoot() + let fs = try HostFS(rootPath: root.url.path) + + #expect(throws: HostFSError.invalidName("..")) { + _ = try fs.lookup(parent: HostFS.rootNodeID, name: "..") + } + #expect(throws: HostFSError.invalidName("a/b")) { + _ = try fs.lookup(parent: HostFS.rootNodeID, name: "a/b") + } + } + + @Test func openReadDoesNotFollowSymlinks() throws { + let root = try TestHostFSRoot() + try root.write("inside", to: "target.txt") + symlink("target.txt", root.url.appendingPathComponent("link.txt").path) + let fs = try HostFS(rootPath: root.url.path) + + let link = try fs.lookup(parent: HostFS.rootNodeID, name: "link.txt") + + #expect(link.attributes.isSymlink) + #expect(throws: HostFSError.notRegularFile(link.nodeID)) { + _ = try fs.openRead(nodeID: link.nodeID) + } + } + + @Test func directoryOpenAsFileIsRejected() throws { + let root = try TestHostFSRoot() + try FileManager.default.createDirectory(at: root.url.appendingPathComponent("dir"), withIntermediateDirectories: false) + let fs = try HostFS(rootPath: root.url.path) + + let dir = try fs.lookup(parent: HostFS.rootNodeID, name: "dir") + + #expect(throws: HostFSError.notRegularFile(dir.nodeID)) { + _ = try fs.openRead(nodeID: dir.nodeID) + } + } + + @Test func createWriteFsyncRenameAndUnlinkAreVisibleOnHost() throws { + let root = try TestHostFSRoot() + let fs = try HostFS(rootPath: root.url.path) + + let created = try fs.createFile(parent: HostFS.rootNodeID, name: "draft.txt") + let handle = try fs.openReadWrite(nodeID: created.nodeID) + try fs.write(handle: handle, offset: 0, data: Array("hello".utf8)) + try fs.write(handle: handle, offset: 5, data: Array(" world".utf8)) + try fs.fsync(handle: handle) + fs.close(handle: handle) + + #expect(try String(contentsOf: root.url.appendingPathComponent("draft.txt"), encoding: .utf8) == "hello world") + + let renamed = try fs.rename(parent: HostFS.rootNodeID, name: "draft.txt", newParent: HostFS.rootNodeID, newName: "final.txt") + + #expect(renamed.name == "final.txt") + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("final.txt").path)) + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("draft.txt").path)) + + try fs.unlink(parent: HostFS.rootNodeID, name: "final.txt") + + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("final.txt").path)) + } + + @Test func mkdirAndRmdirAreVisibleOnHost() throws { + let root = try TestHostFSRoot() + let fs = try HostFS(rootPath: root.url.path) + + let dir = try fs.mkdir(parent: HostFS.rootNodeID, name: "nested") + + #expect(dir.attributes.isDirectory) + #expect(FileManager.default.fileExists(atPath: root.url.appendingPathComponent("nested").path)) + + try fs.rmdir(parent: HostFS.rootNodeID, name: "nested") + + #expect(!FileManager.default.fileExists(atPath: root.url.appendingPathComponent("nested").path)) + } + + @Test func xattrRoundTripsThroughOpenHandle() throws { + let root = try TestHostFSRoot() + try root.write("payload", to: "file.txt") + let fs = try HostFS(rootPath: root.url.path) + let entry = try fs.lookup(parent: HostFS.rootNodeID, name: "file.txt") + let handle = try fs.openReadWrite(nodeID: entry.nodeID) + defer { fs.close(handle: handle) } + + try fs.setXattr(handle: handle, name: "user.dory.test", value: Array("value".utf8)) + + #expect(String(decoding: try fs.getXattr(handle: handle, name: "user.dory.test"), as: UTF8.self) == "value") + #expect(try fs.listXattrs(handle: handle).contains("user.dory.test")) + } + + @Test func readonlyShareRejectsMutatingOperations() throws { + let root = try TestHostFSRoot() + try root.write("payload", to: "file.txt") + let fs = try HostFS(rootPath: root.url.path, readOnly: true) + let entry = try fs.lookup(parent: HostFS.rootNodeID, name: "file.txt") + let readHandle = try fs.openRead(nodeID: entry.nodeID) + defer { fs.close(handle: readHandle) } + + #expect(String(decoding: try fs.read(handle: readHandle, offset: 0, count: 7), as: UTF8.self) == "payload") + #expect(throws: HostFSError.readOnly) { + _ = try fs.openReadWrite(nodeID: entry.nodeID) + } + #expect(throws: HostFSError.readOnly) { + _ = try fs.createFile(parent: HostFS.rootNodeID, name: "new.txt") + } + #expect(throws: HostFSError.readOnly) { + try fs.unlink(parent: HostFS.rootNodeID, name: "file.txt") + } + } +} + +private final class TestHostFSRoot { + let url: URL + + init() throws { + url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("dory-hostfs-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + deinit { + try? FileManager.default.removeItem(at: url) + } + + func write(_ text: String, to relativePath: String) throws { + try text.write(to: url.appendingPathComponent(relativePath), atomically: true, encoding: .utf8) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/HostUsbDeviceTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostUsbDeviceTests.swift new file mode 100644 index 0000000..e09c948 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/HostUsbDeviceTests.swift @@ -0,0 +1,255 @@ +import Darwin +import Foundation +import Testing +@testable import DoryHV + +struct HostUsbDeviceTests { + @Test func discoveryMapsIORegistryPropertiesToUsbipDescriptor() throws { + let candidate = try #require(HostUsbDiscovery.candidate(from: [ + "idVendor": 0x2e8a, + "idProduct": NSNumber(value: 0x0003), + "bcdDevice": "0x0100", + "bDeviceClass": 0xff, + "bDeviceSubClass": 0, + "bDeviceProtocol": 1, + "bConfigurationValue": 1, + "bNumConfigurations": 1, + "bNumInterfaces": 2, + "USB Address": 4, + "locationID": 0x1430_0000, + "Device Speed": 3, + "USB Vendor Name": "Raspberry Pi", + "USB Product Name": "RP2 Boot", + "USB Serial Number": "E0C9125B0D9B", + ])) + + #expect(candidate.descriptor.busID == "20-4") + #expect(candidate.descriptor.busNumber == 20) + #expect(candidate.descriptor.deviceNumber == 4) + #expect(candidate.descriptor.vendorID == 0x2e8a) + #expect(candidate.descriptor.productID == 0x0003) + #expect(candidate.descriptor.bcdDevice == 0x0100) + #expect(candidate.descriptor.interfaceCount == 2) + #expect(candidate.vendorName == "Raspberry Pi") + #expect(candidate.productName == "RP2 Boot") + #expect(candidate.serialNumber == "E0C9125B0D9B") + } + + @Test func discoveryRejectsEntriesWithoutVendorAndProductIDs() { + #expect(HostUsbDiscovery.candidate(from: ["USB Product Name": "Hub"]) == nil) + } + + @Test func discoveryAcceptsExplicitBusIDForStableTests() throws { + let candidate = try #require(HostUsbDiscovery.candidate(from: [ + "DoryBusID": "3-2", + "idVendor": "4660", + "idProduct": "0xabcd", + ])) + + #expect(candidate.descriptor.busID == "3-2") + #expect(candidate.descriptor.vendorID == 0x1234) + #expect(candidate.descriptor.productID == 0xabcd) + } + + @Test func openPlansDescribeAuthorizationAndCaptureRequirements() { + #expect(HostUsbDeviceFactory.plan(mode: .userAuthorized) == HostUsbOpenPlan( + mode: .userAuthorized, + authorize: true, + requiresPrivilegedHelperForClaimedDevice: false, + optionNames: [] + )) + #expect(HostUsbDeviceFactory.plan(mode: .seize) == HostUsbOpenPlan( + mode: .seize, + authorize: true, + requiresPrivilegedHelperForClaimedDevice: false, + optionNames: ["deviceSeize"] + )) + #expect(HostUsbDeviceFactory.plan(mode: .capture) == HostUsbOpenPlan( + mode: .capture, + authorize: true, + requiresPrivilegedHelperForClaimedDevice: true, + optionNames: ["deviceCapture"] + )) + } + + @Test func controlSubmitParsesSetupPacketAndReturnsInPayload() throws { + let backend = RecordingHostUsbBackend(controlResult: HostUsbTransferResult(status: 0, actualLength: 3, data: [1, 2, 3])) + let device = HostUsbDevice(descriptor: fixtureHostUsbDescriptor(), backend: backend) + let command = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 10, deviceID: 1, direction: .in, endpoint: 0), + transferFlags: 0, + transferBufferLength: 3, + startFrame: 0xffff_ffff, + numberOfPackets: 0, + interval: 0, + setup: [0x80, 0x06, 0x00, 0x01, 0x00, 0x00, 0x03, 0x00], + transferBuffer: [] + ) + + let reply = try device.submit(command) + + #expect(backend.controlSetups == [HostUsbControlSetup(requestType: 0x80, request: 0x06, value: 0x0100, index: 0, length: 3)]) + #expect(reply.header.direction == .out) + #expect(reply.header.endpoint == 0) + #expect(reply.status == 0) + #expect(reply.actualLength == 3) + #expect(reply.transferBuffer == [1, 2, 3]) + } + + @Test func controlSetupBuildsNativeIOUSBDeviceRequest() throws { + let setup = try HostUsbControlSetup(usbipSetup: [0x21, 0x09, 0x34, 0x12, 0x78, 0x56, 0xbc, 0x9a]) + + let request = setup.ioUSBDeviceRequest() + + #expect(request.bmRequestType == 0x21) + #expect(request.bRequest == 0x09) + #expect(request.wValue == 0x1234) + #expect(request.wIndex == 0x5678) + #expect(request.wLength == 0x9abc) + } + + @Test func bulkOutSubmitUsesEndpointAddressAndPayload() throws { + let backend = RecordingHostUsbBackend(transferResult: HostUsbTransferResult(status: 0, actualLength: 4)) + let device = HostUsbDevice(descriptor: fixtureHostUsbDescriptor(), backend: backend) + let command = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 11, deviceID: 1, direction: .out, endpoint: 2), + transferFlags: 0, + transferBufferLength: 4, + startFrame: 0xffff_ffff, + numberOfPackets: 0, + interval: 0, + setup: [], + transferBuffer: [9, 8, 7, 6] + ) + + let reply = try device.submit(command) + + #expect(backend.transfers.map(\.endpointAddress) == [0x02]) + #expect(backend.transfers.map(\.payload) == [[9, 8, 7, 6]]) + #expect(reply.header.direction == .out) + #expect(reply.header.endpoint == 0) + #expect(reply.status == 0) + #expect(reply.actualLength == 4) + } + + @Test func interruptInSubmitUsesDirectionalEndpointAddress() throws { + let backend = RecordingHostUsbBackend(transferResult: HostUsbTransferResult(status: 0, actualLength: 2, data: [0xaa, 0xbb])) + let device = HostUsbDevice(descriptor: fixtureHostUsbDescriptor(), backend: backend) + let command = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 12, deviceID: 1, direction: .in, endpoint: 3), + transferFlags: 0, + transferBufferLength: 8, + startFrame: 0xffff_ffff, + numberOfPackets: 0, + interval: 8, + setup: [], + transferBuffer: [] + ) + + let reply = try device.submit(command) + + #expect(backend.transfers.map(\.endpointAddress) == [0x83]) + #expect(backend.transfers.map(\.expectedLength) == [8]) + #expect(reply.header.direction == .out) + #expect(reply.header.endpoint == 0) + #expect(reply.transferBuffer == [0xaa, 0xbb]) + } + + @Test func transferErrorsMapToNegativeUsbipStatus() throws { + let backend = RecordingHostUsbBackend(transferError: .endpointNotFound(0x84)) + let device = HostUsbDevice(descriptor: fixtureHostUsbDescriptor(), backend: backend) + let command = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 13, deviceID: 1, direction: .in, endpoint: 4), + transferFlags: 0, + transferBufferLength: 8, + startFrame: 0xffff_ffff, + numberOfPackets: 0, + interval: 1, + setup: [], + transferBuffer: [] + ) + + let reply = try device.submit(command) + + #expect(reply.status == -ENOENT) + #expect(reply.actualLength == 0) + #expect(reply.header.direction == .out) + #expect(reply.header.endpoint == 0) + } + + @Test func unlinkAbortsBackend() throws { + let backend = RecordingHostUsbBackend() + let device = HostUsbDevice(descriptor: fixtureHostUsbDescriptor(), backend: backend) + let command = UsbipUnlinkCommand( + header: UsbipHeaderBasic(command: .cmdUnlink, sequenceNumber: 14, deviceID: 1, direction: .out, endpoint: 0), + unlinkSequenceNumber: 12 + ) + + let reply = try device.unlink(command) + + #expect(backend.abortEndpoints == [nil]) + #expect(reply.status == 0) + } +} + +private final class RecordingHostUsbBackend: HostUsbBackend, @unchecked Sendable { + struct Transfer: Equatable { + var endpointAddress: UInt8 + var payload: [UInt8] + var expectedLength: UInt32 + var direction: UsbipDirection + } + + var controlSetups: [HostUsbControlSetup] = [] + var controlPayloads: [[UInt8]] = [] + var transfers: [Transfer] = [] + var abortEndpoints: [UInt8?] = [] + var controlResult: HostUsbTransferResult + var transferResult: HostUsbTransferResult + var transferError: HostUsbTransferError? + + init( + controlResult: HostUsbTransferResult = HostUsbTransferResult(status: 0, actualLength: 0), + transferResult: HostUsbTransferResult = HostUsbTransferResult(status: 0, actualLength: 0), + transferError: HostUsbTransferError? = nil + ) { + self.controlResult = controlResult + self.transferResult = transferResult + self.transferError = transferError + } + + func control(_ setup: HostUsbControlSetup, payload: [UInt8], direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + controlSetups.append(setup) + controlPayloads.append(payload) + return controlResult + } + + func transfer(endpointAddress: UInt8, payload: [UInt8], expectedLength: UInt32, direction: UsbipDirection, timeout: TimeInterval) throws -> HostUsbTransferResult { + if let transferError { throw transferError } + transfers.append(Transfer(endpointAddress: endpointAddress, payload: payload, expectedLength: expectedLength, direction: direction)) + return transferResult + } + + func abort(endpointAddress: UInt8?) throws { + abortEndpoints.append(endpointAddress) + } +} + +private func fixtureHostUsbDescriptor() -> UsbipDeviceDescriptor { + UsbipDeviceDescriptor( + path: "/sys/devices/pci0000:00/usb3/3-2", + busID: "3-2", + busNumber: 3, + deviceNumber: 2, + speed: 2, + vendorID: 0x1234, + productID: 0xabcd, + bcdDevice: 0x0100, + deviceClass: 0xff, + deviceSubClass: 0, + deviceProtocol: 1, + configurationValue: 1, + configurationCount: 1, + interfaceCount: 2 + ) +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/MachinePortWatcherTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/MachinePortWatcherTests.swift new file mode 100644 index 0000000..843a1be --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/MachinePortWatcherTests.swift @@ -0,0 +1,78 @@ +import Foundation +import Testing +@testable import DoryHV + +@Suite struct MachinePortWatcherTests { + @Test func watchPortsUsesAgentMethod() async throws { + let transport = StubAgentTransport(responsePayload: #"{"id":1,"result":{"ports":[],"added":[],"removed":[]}}"#) + let channel = AgentChannel(transport: transport) + + let snapshot = try await channel.watchPorts() + let request = try transport.decodedRequest() + + #expect(snapshot == AgentPortSnapshot(ports: [], added: [], removed: [])) + #expect(request.method == "ports.watch") + } + + @Test func watcherExposesAndReleasesTCPDiffsOnly() async throws { + let transport = StubAgentTransport(responsePayload: """ + {"id":1,"result":{ + "ports":[{"protocol":"tcp","port":3000}], + "added":[{"action":"add","protocol":"tcp","port":3000},{"action":"add","protocol":"udp","port":5353}], + "removed":[{"action":"remove","protocol":"tcp6","port":8080},{"action":"remove","protocol":"udp","port":5353}] + }} + """) + let forwarder = FakeMachinePortForwarder() + let watcher = MachinePortWatcher(channel: AgentChannel(transport: transport), forwarder: forwarder) + + let snapshot = try await watcher.pollOnce() + + #expect(snapshot.ports == [AgentListenPort(protocol: "tcp", port: 3000)]) + #expect(await forwarder.exposed == [3000]) + #expect(await forwarder.unexposed == [8080]) + } + + private final class StubAgentTransport: AgentByteTransport { + private var response: [UInt8] + private(set) var written = [UInt8]() + + init(responsePayload: String) { + self.response = (try? AgentFrameCodec.encode(Array(responsePayload.utf8))) ?? [] + } + + func readExact(_ count: Int) async throws -> [UInt8] { + let bytes = Array(response.prefix(count)) + response.removeFirst(min(count, response.count)) + return bytes + } + + func writeAll(_ bytes: [UInt8]) async throws { + written.append(contentsOf: bytes) + } + + func decodedRequest() throws -> CapturedRequest { + let length = try AgentFrameCodec.decodeLength(Array(written.prefix(4))) + let payload = Data(written.dropFirst(4).prefix(length)) + return try JSONDecoder().decode(CapturedRequest.self, from: payload) + } + } + + private struct CapturedRequest: Decodable { + var method: String + } + + private actor FakeMachinePortForwarder: MachinePortForwarding { + private(set) var exposed: [UInt16] = [] + private(set) var unexposed: [UInt16] = [] + + func exposeMachinePort(_ port: UInt16) async -> Bool { + exposed.append(port) + return true + } + + func unexposeMachinePort(_ port: UInt16) async -> Bool { + unexposed.append(port) + return true + } + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift new file mode 100644 index 0000000..87a55ba --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipBridgeTests.swift @@ -0,0 +1,257 @@ +import Foundation +import Testing +@testable import DoryHV + +struct UsbipBridgeTests { + @Test func bridgeAnswersImportThenForwardsSubmitAndClosesOnEOF() throws { + let device = StubExportedDevice(descriptor: fixtureDescriptor(busID: "3-2"), submitPayload: [0xAA, 0xBB]) + let connection = LoopbackVsockConnection() + + // Guest side, in order: OP_REQ_IMPORT for the busID, then one CMD_SUBMIT (IN, 2-byte reply). + connection.feed(UsbipImportRequest(busID: "3-2").encoded()) + let submit = UsbipSubmitCommand( + header: UsbipHeaderBasic(command: .cmdSubmit, sequenceNumber: 7, deviceID: 0, direction: .in, endpoint: 1), + transferFlags: 0, transferBufferLength: 2, startFrame: 0, numberOfPackets: 0, interval: 0, + setup: [UInt8](repeating: 0, count: 8), transferBuffer: [] + ) + connection.feed(submit.encoded()) + connection.finishAfterDrain() + + var closed = false + let bridge = UsbipBridge(connection: connection, device: device) { closed = true } + bridge.serve() + + let written = connection.writes + // 1. OP_REP_IMPORT: 8-byte op header (status 0) then the 312-byte device descriptor. + #expect(written.count > 8 + UsbipDeviceDescriptor.byteCount) + let importReply = Array(written.prefix(8 + UsbipDeviceDescriptor.byteCount)) + let descriptor = try UsbipDeviceDescriptor(decoding: Array(importReply.dropFirst(8))) + #expect(descriptor.busID == "3-2") + // 2. The submit was forwarded to the device and a reply followed the import reply. + #expect(device.submitted.count == 1) + #expect(device.submitted.first?.header.sequenceNumber == 7) + // 3. EOF (peer closed after draining) ended the loop and released the device. + #expect(closed) + } + + @Test func bridgeRejectsUnknownBusIDWithStatusOne() throws { + let device = StubExportedDevice(descriptor: fixtureDescriptor(busID: "3-2")) + let connection = LoopbackVsockConnection() + connection.feed(UsbipImportRequest(busID: "9-9").encoded()) + connection.finishAfterDrain() + + var closed = false + let bridge = UsbipBridge(connection: connection, device: device) { closed = true } + bridge.serve() + + // Unknown device → OP_REP_IMPORT with status 1 and no descriptor (8 bytes total). + let written = connection.writes + #expect(written.count == 8) + let status = (UInt32(written[4]) << 24) | (UInt32(written[5]) << 16) | (UInt32(written[6]) << 8) | UInt32(written[7]) + #expect(status == 1) + #expect(device.submitted.isEmpty) + #expect(closed) + } + + @Test func bridgeStopsImmediatelyIfPeerClosesBeforeImport() throws { + let device = StubExportedDevice(descriptor: fixtureDescriptor(busID: "3-2")) + let connection = LoopbackVsockConnection() + connection.finishAfterDrain() + + var closed = false + let bridge = UsbipBridge(connection: connection, device: device) { closed = true } + bridge.serve() + + #expect(connection.writes.isEmpty) + #expect(closed) + } +} + +struct UsbipManagerTests { + @Test func registerUnregisterTracksClaimedDevicesByBusID() { + let manager = UsbipManager() + #expect(manager.claimedBusIDs.isEmpty) + + manager.register(StubExportedDevice(descriptor: fixtureDescriptor(busID: "3-2"))) + manager.register(StubExportedDevice(descriptor: fixtureDescriptor(busID: "1-4"))) + #expect(manager.claimedBusIDs == ["1-4", "3-2"]) + #expect(manager.exportedDevice(busID: "3-2")?.descriptor.busID == "3-2") + #expect(manager.exportedDevices().count == 2) + + let removed = manager.unregister(busID: "3-2") + #expect(removed?.descriptor.busID == "3-2") + #expect(manager.claimedBusIDs == ["1-4"]) + #expect(manager.exportedDevice(busID: "3-2") == nil) + } + + @Test func portDefaultsToUsbipVsockPort() { + #expect(UsbipManager().port == VsockPorts.usbip) + } +} + +struct UsbControlHandlerTests { + private func makeHandler( + manager: UsbipManager = UsbipManager(), + openFails: Bool = false, + attachFails: Bool = false + ) -> (UsbControlHandler, Box) { + let box = Box() + let handler = UsbControlHandler( + manager: manager, + openDevice: { busID, mode in + box.opened.append((busID, mode)) + if openFails { throw UsbControlError.notAttached(busID) } + return StubExportedDevice(descriptor: fixtureDescriptor(busID: busID)) + }, + notifyAttach: { req in + box.attachCalls.append(req) + if attachFails { throw UsbControlError.notAttached(req.busid) } + }, + notifyDetach: { req in box.detachCalls.append(req) } + ) + return (handler, box) + } + + @Test func attachClaimsRegistersAndNotifiesGuest() async throws { + let manager = UsbipManager() + let (handler, box) = makeHandler(manager: manager) + + let outcome = try await handler.attach(busID: "3-2") + + #expect(box.opened.map(\.0) == ["3-2"]) + #expect(manager.claimedBusIDs == ["3-2"]) + #expect(box.attachCalls.count == 1) + #expect(box.attachCalls.first?.busid == "3-2") + #expect(box.attachCalls.first?.vsock_port == VsockPorts.usbip) + #expect(box.attachCalls.first?.device_id == (UInt32(3) << 16) | 2) // busNumber 3, deviceNumber 2 + #expect(outcome.port == 0) + } + + @Test func attachAllocatesDistinctPortsAndRejectsDuplicate() async throws { + let (handler, _) = makeHandler() + let a = try await handler.attach(busID: "3-2") + let b = try await handler.attach(busID: "1-4") + #expect(Set([a.port, b.port]) == [0, 1]) + await #expect(throws: UsbControlError.self) { _ = try await handler.attach(busID: "3-2") } + } + + @Test func attachRollsBackWhenGuestNotifyFails() async throws { + let manager = UsbipManager() + let (handler, _) = makeHandler(manager: manager, attachFails: true) + + await #expect(throws: (any Error).self) { _ = try await handler.attach(busID: "3-2") } + // The claim must be undone so the device returns to macOS. + #expect(manager.claimedBusIDs.isEmpty) + #expect(handler.attachedBusIDs.isEmpty) + } + + @Test func detachNotifiesGuestUnregistersAndFreesPort() async throws { + let manager = UsbipManager() + let (handler, box) = makeHandler(manager: manager) + _ = try await handler.attach(busID: "3-2") + + try await handler.detach(busID: "3-2") + + #expect(box.detachCalls.map(\.busid) == ["3-2"]) + #expect(manager.claimedBusIDs.isEmpty) + #expect(handler.attachedBusIDs.isEmpty) + // Port is freed for reuse. + let again = try await handler.attach(busID: "3-2") + #expect(again.port == 0) + } + + @Test func detachOfUnknownBusIDThrows() async throws { + let (handler, _) = makeHandler() + await #expect(throws: UsbControlError.self) { try await handler.detach(busID: "9-9") } + } + + @Test func controlCodecRoundTripsRequestAndResponse() throws { + let request = UsbControlRequest(cmd: "attach", busid: "3-2", mode: "capture") + let line = try UsbControlCodec.encodeRequest(request) + #expect(line.last == 0x0a) + #expect(try UsbControlCodec.decodeRequest(line.dropLast()) == request) + + let response = UsbControlResponse.success(UsbAttachOutcome(busID: "3-2", port: 1, vsockPort: 1025, deviceID: 0x30002, speed: 3)) + let rline = try UsbControlCodec.encodeResponse(response) + #expect(try UsbControlCodec.decodeResponse(rline.dropLast()) == response) + + #expect(UsbControlCodec.mode(from: "capture") == .capture) + #expect(UsbControlCodec.mode(from: "seize") == .seize) + #expect(UsbControlCodec.mode(from: nil) == .userAuthorized) + #expect(UsbControlCodec.mode(from: "garbage") == .userAuthorized) + } + + final class Box: @unchecked Sendable { + var opened: [(String, HostUsbOpenMode)] = [] + var attachCalls: [UsbAgentAttachRequest] = [] + var detachCalls: [UsbAgentDetachRequest] = [] + } +} + +private final class StubExportedDevice: UsbipExportedDevice, @unchecked Sendable { + let descriptor: UsbipDeviceDescriptor + let submitPayload: [UInt8] + private(set) var submitted: [UsbipSubmitCommand] = [] + + init(descriptor: UsbipDeviceDescriptor, submitPayload: [UInt8] = []) { + self.descriptor = descriptor + self.submitPayload = submitPayload + } + + func submit(_ command: UsbipSubmitCommand) throws -> UsbipSubmitReply { + submitted.append(command) + let header = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipSubmitReply(header: header, status: 0, actualLength: UInt32(submitPayload.count), transferBuffer: submitPayload) + } + + func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply { + let header = UsbipHeaderBasic(command: .retUnlink, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipUnlinkReply(header: header, status: 0) + } +} + +private func fixtureDescriptor(busID: String) -> UsbipDeviceDescriptor { + UsbipDeviceDescriptor( + path: "/sys/devices/pci0000:00/usb3/\(busID)", + busID: busID, + busNumber: 3, + deviceNumber: 2, + speed: 2, + vendorID: 0x1234, + productID: 0xabcd, + bcdDevice: 0x0100, + deviceClass: 0xff, + deviceSubClass: 0, + deviceProtocol: 1, + configurationValue: 1, + configurationCount: 1, + interfaceCount: 2 + ) +} + +private final class LoopbackVsockConnection: VsockConnection, @unchecked Sendable { + private let lock = NSLock() + private var inbound = [UInt8]() + private var written = [UInt8]() + private var finished = false + private var closed = false + + func feed(_ bytes: [UInt8]) { lock.lock(); inbound.append(contentsOf: bytes); lock.unlock() } + func finishAfterDrain() { lock.lock(); finished = true; lock.unlock() } + var writes: [UInt8] { lock.lock(); defer { lock.unlock() }; return written } + + func read(into buffer: UnsafeMutableRawBufferPointer) throws -> Int { + lock.lock(); defer { lock.unlock() } + let count = min(buffer.count, inbound.count) + guard count > 0 else { return 0 } + inbound.prefix(count).withUnsafeBytes { source in + buffer.baseAddress?.copyMemory(from: source.baseAddress!, byteCount: count) + } + inbound.removeFirst(count) + return count + } + + func write(_ bytes: [UInt8]) throws { lock.lock(); written.append(contentsOf: bytes); lock.unlock() } + func close() { lock.lock(); closed = true; lock.unlock() } + var isPeerClosed: Bool { lock.lock(); defer { lock.unlock() }; return closed || (finished && inbound.isEmpty) } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipProtocolTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipProtocolTests.swift new file mode 100644 index 0000000..4db0f31 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/UsbipProtocolTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing +@testable import DoryHV + +struct UsbipProtocolTests { + @Test func importRequestEncodesVersionOpcodeAndBusID() throws { + let request = UsbipImportRequest(busID: "3-2") + let encoded = request.encoded() + let decoded = try UsbipImportRequest(decoding: encoded) + + #expect(encoded.count == UsbipImportRequest.byteCount) + #expect(encoded.prefix(8).elementsEqual([0x01, 0x11, 0x80, 0x03, 0, 0, 0, 0])) + #expect(Array(encoded[8..<12]) == Array("3-2\0".utf8)) + #expect(decoded == request) + } + + @Test func importReplyIncludesPackedDeviceDescriptorOnSuccess() throws { + let device = fixtureDevice() + let reply = UsbipImportReply(status: 0, device: device) + let encoded = reply.encoded() + let header = try UsbipOperationHeader(decoding: Array(encoded.prefix(8))) + let decodedDevice = try UsbipDeviceDescriptor(decoding: Array(encoded.dropFirst(8))) + + #expect(header.version == UsbipOperationHeader.version) + #expect(header.code == UsbipOpCode.repImport.rawValue) + #expect(header.status == 0) + #expect(encoded.count == 8 + UsbipDeviceDescriptor.byteCount) + #expect(decodedDevice == device) + #expect(encoded[8 + 300] == 0x12) + #expect(encoded[8 + 301] == 0x34) + #expect(encoded[8 + 302] == 0xab) + #expect(encoded[8 + 303] == 0xcd) + } + + @Test func submitInFixtureFromKernelDocsDecodesAndRoundTrips() throws { + let bytes = hex("00000001 00000d05 0001000f 00000001 00000001 00000200 00000040 ffffffff 00000000 00000004 00000000 00000000") + + let command = try UsbipSubmitCommand(decoding: bytes) + + #expect(command.header.command == .cmdSubmit) + #expect(command.header.sequenceNumber == 0x0d05) + #expect(command.header.deviceID == 0x0001_000f) + #expect(command.header.direction == .in) + #expect(command.header.endpoint == 1) + #expect(command.transferBufferLength == 0x40) + #expect(command.startFrame == 0xffff_ffff) + #expect(command.numberOfPackets == 0) + #expect(command.interval == 4) + #expect(command.transferBuffer.isEmpty) + #expect(command.encoded() == bytes) + } + + @Test func submitOutFixtureCarriesTransferBuffer() throws { + let bytes = hex(""" + 00000001 00000d06 0001000f 00000000 00000001 00000000 00000040 ffffffff 00000000 00000004 00000000 00000000 + ffffffff860008a784ce5ae212376300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + """) + + let command = try UsbipSubmitCommand(decoding: bytes) + + #expect(command.header.direction == .out) + #expect(command.transferBufferLength == 0x40) + #expect(command.transferBuffer.count == 0x40) + #expect(command.transferBuffer.prefix(4).elementsEqual([0xff, 0xff, 0xff, 0xff])) + #expect(command.encoded() == bytes) + } + + @Test func submitReplyEncodesPayloadWithServerDirectionHeader() throws { + let header = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: 0x0d05, deviceID: 0, direction: .out, endpoint: 0) + let reply = UsbipSubmitReply(header: header, status: 0, actualLength: 4, transferBuffer: [1, 2, 3, 4, 5]) + let encoded = reply.encoded() + + #expect(encoded.count == UsbipSubmitReply.headerByteCount + 4) + #expect(encoded.prefix(4).elementsEqual([0, 0, 0, 3])) + #expect(encoded[12..<16].elementsEqual([0, 0, 0, 0])) + #expect(Array(encoded.suffix(4)) == [1, 2, 3, 4]) + } + + @Test func unlinkCommandAndReplyRoundTripHeaders() throws { + let command = UsbipUnlinkCommand( + header: UsbipHeaderBasic(command: .cmdUnlink, sequenceNumber: 22, deviceID: 0x0001_000f, direction: .out, endpoint: 0), + unlinkSequenceNumber: 21 + ) + let encoded = command.encoded() + let decoded = try UsbipUnlinkCommand(decoding: encoded) + + #expect(encoded.count == UsbipUnlinkCommand.byteCount) + #expect(decoded == command) + + let reply = UsbipUnlinkReply( + header: UsbipHeaderBasic(command: .retUnlink, sequenceNumber: 22, deviceID: 0, direction: .out, endpoint: 0), + status: -54 + ) + let replyBytes = reply.encoded() + + #expect(replyBytes.count == UsbipUnlinkReply.byteCount) + #expect(replyBytes[0..<4].elementsEqual([0, 0, 0, 4])) + #expect(replyBytes[20..<24].elementsEqual([0xff, 0xff, 0xff, 0xca])) + } + + @Test func shortFramesAreRejected() { + #expect(throws: UsbipProtocolError.shortFrame) { + _ = try UsbipSubmitCommand(decoding: [0, 1, 2]) + } + #expect(throws: UsbipProtocolError.shortFrame) { + _ = try UsbipImportRequest(decoding: [0, 1, 2]) + } + } +} + +private func fixtureDevice() -> UsbipDeviceDescriptor { + UsbipDeviceDescriptor( + path: "/sys/devices/pci0000:00/usb3/3-2", + busID: "3-2", + busNumber: 3, + deviceNumber: 2, + speed: 2, + vendorID: 0x1234, + productID: 0xabcd, + bcdDevice: 0x0100, + deviceClass: 0xff, + deviceSubClass: 0, + deviceProtocol: 1, + configurationValue: 1, + configurationCount: 1, + interfaceCount: 2 + ) +} + +private func hex(_ string: String) -> [UInt8] { + let compact = string.filter { $0.isHexDigit } + var bytes = [UInt8]() + var index = compact.startIndex + while index < compact.endIndex { + let next = compact.index(index, offsetBy: 2) + bytes.append(UInt8(compact[index.. UsbipSubmitReply { + submitted.append(command) + let header = UsbipHeaderBasic(command: .retSubmit, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipSubmitReply(header: header, status: 0, actualLength: UInt32(submitPayload.count), transferBuffer: submitPayload) + } + + func unlink(_ command: UsbipUnlinkCommand) throws -> UsbipUnlinkReply { + unlinked.append(command) + let header = UsbipHeaderBasic(command: .retUnlink, sequenceNumber: command.header.sequenceNumber, deviceID: 0, direction: .out, endpoint: 0) + return UsbipUnlinkReply(header: header, status: 0) + } +} + +private func fixtureUsbDevice() -> UsbipDeviceDescriptor { + UsbipDeviceDescriptor( + path: "/sys/devices/pci0000:00/usb3/3-2", + busID: "3-2", + busNumber: 3, + deviceNumber: 2, + speed: 2, + vendorID: 0x1234, + productID: 0xabcd, + bcdDevice: 0x0100, + deviceClass: 0xff, + deviceSubClass: 0, + deviceProtocol: 1, + configurationValue: 1, + configurationCount: 1, + interfaceCount: 2 + ) +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift new file mode 100644 index 0000000..ed747d0 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSShareConfigurationTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing +@testable import DoryHV + +struct VirtioFSShareConfigurationTests { + @Test func parsesReadWriteShareArgument() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/Users/example/project") + + #expect(share.tag == "src") + #expect(share.path == "/Users/example/project") + #expect(!share.readOnly) + } + + @Test func parsesReadOnlyAndExplicitReadWriteSuffixes() throws { + let readOnly = try VirtioFSShareConfiguration(argument: "cache=/tmp/cache:ro") + let readWrite = try VirtioFSShareConfiguration(argument: "work=/tmp/work:rw") + + #expect(readOnly.path == "/tmp/cache") + #expect(readOnly.readOnly) + #expect(readWrite.path == "/tmp/work") + #expect(!readWrite.readOnly) + } + + @Test func rejectsInvalidShareArguments() { + #expect(throws: (any Error).self) { + _ = try VirtioFSShareConfiguration(argument: "missing-equals") + } + #expect(throws: (any Error).self) { + _ = try VirtioFSShareConfiguration(argument: "bad/tag=/tmp") + } + #expect(throws: (any Error).self) { + _ = try VirtioFSShareConfiguration(argument: "empty=") + } + } + + @Test func defaultsToDaxDisabled() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/Users/example/project") + #expect(!share.dax) + } + + @Test func parsesDaxSuffix() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/Users/example/project:dax") + #expect(share.path == "/Users/example/project") + #expect(share.dax) + #expect(!share.readOnly) + } + + @Test func parsesCombinedReadOnlyAndDaxSuffixesInAnyOrder() throws { + let a = try VirtioFSShareConfiguration(argument: "cache=/tmp/cache:ro:dax") + let b = try VirtioFSShareConfiguration(argument: "cache=/tmp/cache:dax:ro") + for share in [a, b] { + #expect(share.path == "/tmp/cache") + #expect(share.readOnly) + #expect(share.dax) + } + } + + @Test func parsesGuestMountPointFromAtOption() throws { + let share = try VirtioFSShareConfiguration(argument: "home=/Users/example:rw:at=/Users/example") + #expect(share.path == "/Users/example") + #expect(share.guestMountPoint == "/Users/example") + #expect(!share.readOnly) + } + + @Test func defaultsGuestMountPointToNil() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/Users/example/project") + #expect(share.guestMountPoint == nil) + } + + @Test func safeOptionAppliesSensitiveNameDenylist() throws { + let share = try VirtioFSShareConfiguration(argument: "home=/Users/example:rw:at=/Users/example:safe") + #expect(share.hiddenNames == VirtioFSShareConfiguration.sensitiveNames) + #expect(share.hiddenNames.contains(".ssh")) + #expect(share.hiddenNames.contains(".aws")) + } + + @Test func hideOptionAddsExplicitNames() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/tmp/x:hide=secrets,.env") + #expect(share.hiddenNames == ["secrets", ".env"]) + } + + @Test func defaultsToNoHiddenNames() throws { + let share = try VirtioFSShareConfiguration(argument: "src=/tmp/x") + #expect(share.hiddenNames.isEmpty) + } + + @Test func rejectsRelativeGuestMountPoint() { + #expect(throws: (any Error).self) { + _ = try VirtioFSShareConfiguration(argument: "home=/Users/example:at=relative/path") + } + } + + @Test func makeBackendWithoutDaxHasNoDaxConfiguration() throws { + let dir = FileManager.default.temporaryDirectory.path + let share = try VirtioFSShareConfiguration(argument: "t=\(dir)") + let device = try share.makeBackend(daxGuestBase: GuestLayout.daxWindowBase) + #expect(device.daxConfiguration == nil) + } + + @Test func makeBackendWithDaxUsesProvidedGuestBase() throws { + let dir = FileManager.default.temporaryDirectory.path + let share = try VirtioFSShareConfiguration(argument: "t=\(dir):dax") + let device = try share.makeBackend(daxGuestBase: GuestLayout.daxWindowBase) + #expect(device.daxConfiguration?.guestBase == GuestLayout.daxWindowBase) + #expect(device.sharedMemoryRegions.count == 1) + } + + @Test func makeBackendWithDaxButNoBaseThrows() { + let dir = FileManager.default.temporaryDirectory.path + #expect(throws: (any Error).self) { + let share = try VirtioFSShareConfiguration(argument: "t=\(dir):dax") + _ = try share.makeBackend(daxGuestBase: nil) + } + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift new file mode 100644 index 0000000..3f96308 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioFSTests.swift @@ -0,0 +1,67 @@ +import Foundation +import Testing +@testable import DoryHV + +struct VirtioFSTests { + @Test func exposesVirtioFSDeviceIdentityAndQueues() throws { + let root = try TestVirtioFSRoot() + let fs = try VirtioFS(tag: "home", hostFS: HostFS(rootPath: root.url.path)) + + #expect(fs.deviceID == 26) + #expect(fs.queueCount == 2) + #expect(fs.deviceFeatures & VirtioFS.notificationFeature == 0) + } + + @Test func configSpaceContainsPaddedTagAndOneRequestQueue() throws { + let root = try TestVirtioFSRoot() + let fs = try VirtioFS(tag: "home", hostFS: HostFS(rootPath: root.url.path)) + let config = fs.configSpace + + #expect(config.count == 40) + #expect(String(decoding: config[0..<4], as: UTF8.self) == "home") + #expect(config[4..= 49_152) + } + + @Test func hostConnectionWritesReadWritePacketsAfterResponse() throws { + let device = VirtioVsock(guestCID: 3) + let connection = device.connect(port: 1024) + let request = try VirtioVsockHeader(decoding: device.drainPendingGuestPackets()[0]) + let response = VirtioVsockHeader( + sourceCID: 3, + destinationCID: 2, + sourcePort: 1024, + destinationPort: request.sourcePort, + length: 0, + operation: .response + ) + _ = try device.receive(packet: response.encoded()) + + try connection.write([1, 2, 3, 4]) + let packets = device.drainPendingGuestPackets() + #expect(packets.count == 1) + let rw = try VirtioVsockHeader(decoding: packets[0]) + #expect(rw.operation == .readWrite) + #expect(rw.sourcePort == request.sourcePort) + #expect(rw.destinationPort == 1024) + #expect(rw.length == 4) + #expect(Array(packets[0].dropFirst(VirtioVsockHeader.byteCount)) == [1, 2, 3, 4]) + } + + @Test func hostConnectionReadsGuestPayload() throws { + let device = VirtioVsock(guestCID: 3) + let connection = device.connect(port: 1024) + let request = try VirtioVsockHeader(decoding: device.drainPendingGuestPackets()[0]) + let payload = [UInt8]("pong".utf8) + let rw = VirtioVsockHeader( + sourceCID: 3, + destinationCID: 2, + sourcePort: 1024, + destinationPort: request.sourcePort, + length: UInt32(payload.count), + operation: .readWrite + ) + _ = try device.receive(packet: rw.encoded() + payload) + + var buffer = [UInt8](repeating: 0, count: 8) + let count = try buffer.withUnsafeMutableBytes { try connection.read(into: $0) } + #expect(count == payload.count) + #expect(Array(buffer.prefix(count)) == payload) + } +} diff --git a/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift new file mode 100644 index 0000000..ef100e5 --- /dev/null +++ b/Packages/ContainerizationEngine/Tests/DoryHVTests/VirtqueueTests.swift @@ -0,0 +1,114 @@ +import Foundation +import Testing +@testable import DoryHV + +/// Exercises the split-virtqueue descriptor walk and used-ring publish directly against a +/// GuestMemory mmap. No hypervisor is involved: GuestMemory is a plain anonymous region and the +/// Virtqueue only reads/writes guest addresses, so this runs anywhere. +@Suite struct VirtqueueTests { + private let base: UInt64 = 0x8000_0000 + private let descTable: UInt64 = 0x8000_1000 + private let availRing: UInt64 = 0x8000_2000 + private let usedRing: UInt64 = 0x8000_3000 + private let dataOut: UInt64 = 0x8000_4000 + private let dataIn: UInt64 = 0x8000_5000 + + private func makeMemory() throws -> GuestMemory { + try GuestMemory(guestBase: base, size: 64 * 16384) + } + + private func writeDescriptor(_ memory: GuestMemory, index: UInt64, addr: UInt64, len: UInt32, flags: UInt16, next: UInt16) throws { + let d = descTable + index * 16 + try memory.write(addr, at: d) + try memory.write(len, at: d + 8) + try memory.write(flags, at: d + 12) + try memory.write(next, at: d + 14) + } + + @Test func popResolvesAChainAndPushPublishesUsed() throws { + let memory = try makeMemory() + let queue = Virtqueue(memory: memory) + queue.configure(size: 8, descriptorTable: descTable, availRing: availRing, usedRing: usedRing) + queue.setReady(true) + + // One readable descriptor (device reads it) chained to one writable descriptor. + try writeDescriptor(memory, index: 0, addr: dataOut, len: 4, flags: 0x1, next: 1) // NEXT + try writeDescriptor(memory, index: 1, addr: dataIn, len: 8, flags: 0x2, next: 0) // WRITE + try memory.write([0xDE, 0xAD, 0xBE, 0xEF], at: dataOut) + + // avail ring: flags(2) idx(2) then ring[]; publish head 0 at slot 0, idx=1. + try memory.write(UInt16(0), at: availRing) // flags + try memory.write(UInt16(0), at: availRing + 4) // ring[0] = descriptor head 0 + try memory.write(UInt16(1), at: availRing + 2) // idx = 1 + + #expect(queue.hasPending) + let chain = try #require(try queue.pop()) + #expect(chain.head == 0) + #expect(chain.readableSegments.count == 1) + #expect(chain.writableSegments.count == 1) + #expect(chain.readBytes() == [0xDE, 0xAD, 0xBE, 0xEF]) + + let wrote = chain.writeBytes([1, 2, 3, 4, 5]) + #expect(wrote == 5) + #expect(try memory.readBytes(at: dataIn, count: 5) == [1, 2, 3, 4, 5]) + + // Publishing marks the used ring: idx advances and the element records head 0 + written. + try queue.push(chain, written: 5) + #expect(try memory.read(UInt16.self, at: usedRing + 2) == 1) // used idx + #expect(try memory.read(UInt32.self, at: usedRing + 4) == 0) // used elem id = head + #expect(try memory.read(UInt32.self, at: usedRing + 8) == 5) // used elem len + #expect(try queue.pop() == nil) // ring drained + } + + @Test func pushIsSafeAfterResetToZeroSize() throws { + let memory = try makeMemory() + let queue = Virtqueue(memory: memory) + queue.configure(size: 8, descriptorTable: descTable, availRing: availRing, usedRing: usedRing) + queue.setReady(true) + try writeDescriptor(memory, index: 0, addr: dataIn, len: 8, flags: 0x2, next: 0) + try memory.write(UInt16(0), at: availRing) + try memory.write(UInt16(0), at: availRing + 4) + try memory.write(UInt16(1), at: availRing + 2) + let chain = try #require(try queue.pop()) + + queue.reset() // guest resets the ring mid-flight (size -> 0) + // push must not divide by zero; it returns false rather than trapping. + #expect(try queue.push(chain, written: 0) == false) + } + + @Test func rejectsOutOfBoundsDescriptorChain() throws { + let memory = try makeMemory() + let queue = Virtqueue(memory: memory) + queue.configure(size: 4, descriptorTable: descTable, availRing: availRing, usedRing: usedRing) + queue.setReady(true) + // head points past the table size. + try memory.write(UInt16(0), at: availRing) + try memory.write(UInt16(99), at: availRing + 4) + try memory.write(UInt16(1), at: availRing + 2) + #expect(throws: (any Error).self) { _ = try queue.pop() } + } +} + +@Suite struct GuestMemoryReclaimGuardTests { + @Test func releaseRangeRejectsUnalignedAndOutOfBounds() throws { + let memory = try GuestMemory(guestBase: 0x8000_0000, size: 64 * 16384) + // Unaligned start. + #expect(!memory.releaseRange(guestAddress: 0x8000_0001, length: 16384)) + // Unaligned length. + #expect(!memory.releaseRange(guestAddress: 0x8000_0000, length: 4096)) + // Out of bounds. + #expect(!memory.releaseRange(guestAddress: 0x9000_0000, length: 16384)) + } + + @Test func restorePageNeverDoubleCountsOrTouchesOutOfRange() throws { + let memory = try GuestMemory(guestBase: 0x8000_0000, size: 64 * 16384) + // Outside RAM is a genuine fault the caller must surface: false, no counters moved. + #expect(!memory.restorePage(guestAddress: 0x7000_0000)) + // An in-RAM page whose released-bit is clear was never unmapped (the releaseRange lock + // makes "unmapped implies bit set" an invariant), so this is a benign no-op: it reports + // success (the guest retry resolves) but must NOT charge a restore. + #expect(memory.restorePage(guestAddress: 0x8000_4000)) + #expect(memory.releasedBytes.load(ordering: .relaxed) == 0) + #expect(memory.restoredBytes.load(ordering: .relaxed) == 0) + } +} diff --git a/README.md b/README.md index 2bec6ff..174e8b9 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,17 @@ Dory selects a backend automatically; `DORY_RUNTIME` overrides it. All share one ## Requirements -- macOS 15 or later, **Intel or Apple silicon**: the app ships as a universal binary. On Intel - (or older macOS), Dory pairs with any Docker-compatible engine: Colima, Docker Desktop, - Rancher Desktop, Podman, or OrbStack. -- macOS 26 (Tahoe) or later on Apple silicon for Dory's standalone Shared VM / Apple `container` backends -- Xcode 27 or later (to build from source) +> **This release: Dory's own engine requires Apple silicon.** The low-memory `dory-hv` engine is a +> from-scratch Apple-silicon (ARM) hypervisor, so the self-contained, memory-efficient experience is +> **Apple silicon only for now**. Native Intel support is planned for a later update — see below. + +- **Apple silicon, macOS 15 (Sequoia) or later** — full experience: Dory's own bundled engine, one + shared VM, low memory, Kubernetes, Linux machines, `*.dory.local` domains. Nothing else to install. +- **Intel Macs** — Dory runs as a native front-end (app, CLI, and `docker` context) for a + Docker-compatible engine you install separately (Colima, Docker Desktop, Rancher Desktop, Podman, + or OrbStack). There is **no bundled engine on Intel yet**; a native Intel engine (via + Virtualization.framework) is on the roadmap for a later update. +- Xcode 27 or later (to build from source). ## Build & run from source diff --git a/docs/research/file-sharing-benchmark.json b/docs/research/file-sharing-benchmark.json new file mode 100644 index 0000000..9470d2a --- /dev/null +++ b/docs/research/file-sharing-benchmark.json @@ -0,0 +1,16 @@ +{ + "date": "2026-07-04", + "host": "Apple Silicon macOS 27.0", + "workload": "fio randread bs=4k ioengine=psync; invalidate=0 keeps guest page cache (realistic), invalidate=1 bypasses it (raw plane)", + "note": "Dory: real dory-hv guest (kernel 6.12.30-dory) mounting a virtio-fs --share of a macOS dir; DAX via --share :dax + mount -o dax=always. OrbStack: container bind mount. Docker Desktop not installed.", + "results": [ + { "engine": "dory-virtiofs-plain", "case": "single-cached", "iops": 1075000 }, + { "engine": "dory-virtiofs-plain", "case": "single-raw", "iops": 20100 }, + { "engine": "dory-virtiofs-plain", "case": "concurrent16-raw","iops": 43000 }, + { "engine": "dory-virtiofs-dax", "case": "single-cached", "iops": 1186000 }, + { "engine": "dory-virtiofs-dax", "case": "single-raw", "iops": 1074000 }, + { "engine": "dory-virtiofs-dax", "case": "concurrent16-raw","iops": 4147000 }, + { "engine": "orbstack", "case": "single-cached", "iops": 1011000 }, + { "engine": "orbstack", "case": "single-raw", "iops": 223000 } + ] +} diff --git a/docs/research/file-sharing.md b/docs/research/file-sharing.md new file mode 100644 index 0000000..f618f54 --- /dev/null +++ b/docs/research/file-sharing.md @@ -0,0 +1,122 @@ +# Dory virtio-fs file sharing: benchmark and analysis + +Date: 2026-07-04. Host: Apple Silicon, macOS 27.0. Measured end to end on a real dory-hv guest +(kernel 6.12.30-dory) mounting a virtio-fs `--share` of a macOS directory, vs OrbStack via a +container bind mount of the same directory. Docker Desktop not installed on this host. + +## Headline + +A caching-correctness bug made every virtio-fs read — even a re-read of the same bytes — take a full +FUSE round-trip. After fixing it, plain virtio-fs on the realistic (cache-resident) workload matches +and slightly beats OrbStack. The remaining gap is only on the deliberately cache-bypassed data plane, +which is a per-request-latency problem for DAX / passthrough to close, not the plain-path gate. + +## Results (fio 4k random read, same params on both engines) + +| Workload | Dory before | Dory plain virtio-fs | Dory DAX | OrbStack | +|---|---|---|---|---| +| Single, cache-resident (`invalidate=0`) | ~18k (broken) | 1,075k | **1,186k** | 1,011k | +| Single, raw / cache-bypassed (`invalidate=1`) | 18k | 20.1k | **1,074k** | 223k | +| 16 parallel jobs, raw | ~28k | 43k | **4,147k** | — | + +Two independent wins: +- **Plain virtio-fs** now matches/beats OrbStack on the realistic cache-resident workload (1.06x), passing + the "not broken, within 1.5x" gate — after fixing page-cache retention (below). +- **DAX** removes the per-read guest<->host round-trip entirely, so even the deliberately cache-bypassed + raw path hits 1,074k IOPS — **54x** over plain virtio-fs and **~4.8x past OrbStack** on the one path + OrbStack was winning. DAX maps file pages directly into guest memory via `hv_vm_map`; reads are plain + loads with zero FUSE traffic. Enable with `--share tag=/path:dax` and `mount -o dax=always`. + +## The bug: the guest page cache was never retained + +Diagnostic (read a stable 128 MiB file twice inside the guest): + +| | before fix | after fix | +|---|---|---| +| `Cached:` in /proc/meminfo after read #1 | did not grow | +131 MiB | +| read #2 wall time | same as read #1 | 0.03s (~4.3 GB/s, cache hit) | + +Before the fix a re-read cost the same as a cold read — nothing was cached. Two mistakes in the FUSE +attribute replies combined to defeat the guest page cache: + +1. **LOOKUP returned `attr_valid = 0`** (`fuse_entry_out`), so the guest treated cached metadata as + immediately stale and revalidated on essentially every access. +2. **Every attr reply carried `mtime_nsec = 0`** (only whole seconds were reported). We advertise + `FUSE_AUTO_INVAL_DATA`, under which the kernel drops the page cache when a cached read observes a + changed mtime. Because the real host mtime has nonzero nanoseconds but we always reported `.000`, + an unchanged file looked modified on every revalidation, so the cache was thrown away each time. + +Fix: report a 1-second `attr_valid`/`entry_valid` window and the real host `atime/mtime/ctime` +nanoseconds. `FUSE_AUTO_INVAL_DATA` is kept — with correct nsecs it now invalidates only on a genuine +host change, which is the correct cache=auto behavior (a shared file the host edits becomes visible +within the 1s window / on next open). + +## The secondary fix: FUSE processing off the vCPU thread + +Independently, request handling used to run synchronously on the vCPU thread inside the MMIO +queue-notify exit — a blocking `pread` per request with the guest frozen until it completed, so a deep +request queue collapsed to effective depth 1. It now dispatches to a small pool of drainers +(bounded by CPU count, out-of-order completion) so the vCPU returns immediately and concurrent +readers run in parallel. This lifted the raw concurrent number (28k → 43k pre-cache-fix) and is the +architecturally-correct model (no production virtio-fs backend blocks the vCPU). Also fixed +`FUSE_MAX_PAGES` so the advertised 256-page (1 MiB) request size is actually honored instead of the +guest clamping to 128 KiB. + +## The raw-plane latency, and how DAX closed it + +Measured breakdown of a plain-virtio-fs cache-bypassed 4k read (~50 µs): ~10 µs host `pread` (SSD for +the random working set), ~10 µs Swift per-request overhead (now removed by zero-copy `preadv`), and +~40 µs of guest<->host round-trip (a vCPU MMIO-exit per kick + a GIC interrupt per completion). No +amount of FUSE-server tuning removes the 40 µs round-trip — the only fix is to stop doing a round-trip +per read. + +**DAX does exactly that** and is now working end to end. Getting there required three fixes, all found +by booting a real guest: enabling the `ZONE_DEVICE -> FS_DAX -> FUSE_DAX` kernel-config chain (defconfig +drops it, so the guest never even compiled DAX support); correcting `fuse_setupmapping_in` from 48 to its +real 40 bytes (every SETUPMAPPING was rejected as a short frame); and mapping the host region read-write +(Apple's `hv_vm_map` rejects a read-only host mapping) while keeping the guest's stage-2 protection +read-only. With DAX, the raw 4k-randread number is 1,074k IOPS — the round-trip is gone. + +## Zero-config sharing (the OrbStack "just works" default) + +The benchmarks above ran through a manual `dory-hv boot --share` harness. In the app, sharing is +now wired automatically: the engine shares the user's home directory at its identical guest path +(`home=$HOME:rw:at=$HOME:safe`), so a bind mount like `docker run -v ~/project:/app` resolves with no +configuration — the guest sees `~/project` at the same path, which is the virtio-fs mount. This uses +**plain** virtio-fs, not DAX (DAX stays opt-in via `:dax` for the raw-plane win): plain matches +OrbStack on realistic cache-resident workloads with none of DAX's window-thrashing or read-only-file +caveats. + +**Security — the `:safe` denylist.** Sharing the whole home tree read-write would expose credential +stores (`~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.kube`, …), shell rc files, and `~/Library` to every +container — a real exfiltration/rc-poisoning risk. `:safe` applies `VirtioFSShareConfiguration.sensitiveNames`, +which `HostFS` hides by name at any depth: a lookup of a hidden name fails as not-found and hidden +entries are omitted from directory listings, so no container can read or overwrite them. This is +defense-in-depth for the convenience share; the stronger guarantee — sharing only the exact paths a +container `-v`-mounts, nothing else — is per-bind-mount **on-demand sharing**, tracked as a follow-up +(it needs the transparent-proxy shim to intercept `/containers/create` plus an engine-side authorization +channel, so it is a multi-component change, not a flag). + +Verified end to end on a real dory-hv guest: with `:safe`, `~/.ssh`/`~/.aws`/`~/.zshrc` are invisible +(lookup fails, absent from `ls`) while `~/project` reads, writes, and lists normally. Separately, the +guest now advertises `FUSE_DO_READDIRPLUS` so shared directories actually enumerate — without it the +server saw plain `FUSE_READDIR` (unhandled) and every `ls` returned empty. + +## Write correctness + +Writes go through the same FUSE server for both plain and DAX shares. `SETATTR` now honors +`FATTR_SIZE`, so `truncate()` and `O_TRUNC` opens actually resize the host file — previously the size +field was dropped and an overwrite left a stale tail (we do not negotiate `atomic_o_trunc`, so the +guest relies on a separate `SETATTR size=0` for every `O_TRUNC`). Verified in a guest with +`-o dax=always`: O_TRUNC (41→3 bytes), ftruncate shrink/grow, extending write (0→4 MiB), create, +in-place overwrite, and write/read coherency all persist correctly to the host. + +## Reproducing + +```sh +guest/kernel/build.sh # build guest/out/Image +dory-hv boot --kernel guest/out/Image --disk \ + --share bench=:rw --cmdline "console=ttyAMA0 root=/dev/vda rw init=/init" +# in the guest: fio --rw=randread --bs=4k --ioengine=psync --invalidate=0|1 --numjobs=1|16 ... +scripts/benchmark.sh fileshare # competitors, auto-detected sockets +``` diff --git a/docs/research/gpu-venus.md b/docs/research/gpu-venus.md new file mode 100644 index 0000000..05e133d --- /dev/null +++ b/docs/research/gpu-venus.md @@ -0,0 +1,88 @@ +# GPU Compute In Guest: Virtio-GPU Venus Research + +Date: 2026-07-04 + +## Summary + +Verdict: do not commit to a production GPU compute feature yet. The stack is real and worth a spike, but Dory should treat it as an experimental `dory-hv --gpu=venus` path with a llama.cpp Vulkan benchmark as the first acceptance gate. + +The strongest route is: + +1. Guest Mesa Venus driver serializes Vulkan calls over virtio-gpu. +2. DoryHV implements virtio-gpu with blob resources, host-visible memory, context init, fences, and a host memory window. +3. Host side uses virglrenderer with Venus enabled. +4. virglrenderer calls host Vulkan, provided on macOS through MoltenVK over Metal. + +That matches the known macOS container direction from libkrun and Podman, but Dory cannot inherit it directly because Dory owns its VMM and device model. + +## Evidence + +Mesa documents Venus as the virtio-gpu protocol for Vulkan command serialization and lists the required virtio-gpu kernel parameters: 3D features, capset query fix, resource blobs, host visible memory, and context init. Mesa also says this normally means a guest kernel at least 5.16 or backports plus a compatible hypervisor. Source: https://docs.mesa3d.org/drivers/venus.html + +QEMU documents virtio-gpu as the paravirtual GPU and display controller, requiring `CONFIG_DRM_VIRTIO_GPU` in the guest kernel. Its Venus path requires `hostmem`, `blob`, and `venus` on the virtio-gpu device, with the host memory window typically between 256 MiB and 8 GiB. Source: https://qemu.readthedocs.io/en/v9.2.4/system/devices/virtio-gpu.html + +virglrenderer exposes a `VIRGL_RENDERER_VENUS` feature flag in its public header, and also has render-server support that is respected by the Venus renderer. Source: https://android.googlesource.com/platform/external/virglrenderer/+/upstream-master/src/virglrenderer.h + +Khronos describes Vulkan Portability as the standardized path for layered Vulkan implementations over Metal and names MoltenVK as a leading implementation on Apple platforms. Source: https://www.vulkan.org/porting + +MoltenVK describes itself as a Vulkan 1.4 graphics and compute implementation layered over Apple's Metal framework, with SPIR-V translated to Metal Shading Language. Source: https://github.com/KhronosGroup/MoltenVK + +Red Hat's June 2025 write-up for macOS Podman containers describes the same functional stack: Mesa Venus in the guest, virglrenderer on the host, and MoltenVK translating Vulkan to Apple Metal. Their llama.cpp/RamaLama test reports a 40x improvement over the prior macOS container GPU computing baseline, but also notes ongoing upstream work around virtio-gpu shared memory page negotiation. Source: https://developers.redhat.com/articles/2025/06/05/how-we-improved-ai-inference-macos-podman-containers + +## Dory Requirements + +Guest kernel: + +- Add `CONFIG_DRM=y`, `CONFIG_DRM_VIRTIO_GPU=y`, `CONFIG_SYNC_FILE=y`, DMA-BUF support, and any DRM helpers required by Linux 6.12. +- Keep `CONFIG_VIRTIO_MMIO=y`; Dory does not use PCI in the hot path. +- Ship Mesa with Venus ICD in the guest image or inject it into GPU-enabled machine images. + +DoryHV device model: + +- Implement virtio-gpu device ID 16 with control and cursor queues. +- Implement 2D resource commands enough for Linux driver initialization even if compute is the target. +- Implement resource blob creation, host-visible memory, context initialization, capset query, fences, and shared-memory or hostmem window plumbing. +- Decide whether the host renderer runs in-process or out-of-process. Out-of-process is preferred for crash isolation and matches virglrenderer render-server direction. +- Add a capability check so GPU support fails closed when MoltenVK or virglrenderer is unavailable. + +Host dependencies: + +- Bundle or locate a pinned virglrenderer build with Venus enabled. +- Bundle or locate MoltenVK from the Vulkan SDK or a pinned runtime artifact. +- Keep app size budget in view. If bundled dylibs push the zip over target, make GPU an external developer-preview install. + +Validation: + +- `vulkaninfo` inside the guest sees a non-lavapipe physical device through Venus. +- `vkcube` or `vkcube-wayland` runs under a machine profile. +- `llama.cpp` with `GGML_VULKAN=1` runs a small model and beats CPU-only by at least 5x on Apple silicon. +- Dory remains stable if the render server crashes. + +## Risks + +- MoltenVK is a portability implementation, not native Vulkan. Feature gaps matter for compute workloads, especially matrix-oriented AI kernels. +- Venus relies on host-visible memory behavior that Mesa documents as constrained and partly implementation-dependent. Dory's Hypervisor.framework memory model must be proven with blob resources before product work. +- Most upstream examples assume KVM, PCI, memfd, or QEMU glue. Dory uses Hypervisor.framework and virtio-mmio, so the hard part is the device/backend bridge, not the guest driver. +- Shipping GPU support safely likely means an extra process boundary. That is more packaging and lifecycle work. +- This does not help Metal-native workloads in the guest. It is Vulkan API forwarding for Linux workloads. + +## Recommended Spike + +1. Add a hidden `--gpu=venus` flag and guest kernel config symbols only. +2. Bring up a tiny virtio-gpu device until Linux binds `virtio_gpu`. +3. Add capset query and blob resource plumbing against a mocked renderer first. +4. Integrate virglrenderer in a helper process and pass commands over a local socket. +5. Run `vulkaninfo`, then a small llama.cpp Vulkan benchmark. + +Go criteria: + +- Guest `vulkaninfo` succeeds through Venus on macOS 15+ Apple silicon. +- llama.cpp Vulkan is at least 5x faster than CPU-only in the Dory guest. +- Renderer crashes do not crash the VM or app. +- Added compressed artifacts keep the app zip under the roadmap budget. + +No-go fallback: + +- Keep GPU compute as documented research. +- Prefer host-side model runners integrated with Dory's reverse proxy and machine filesystem sharing. +- Revisit when virglrenderer, MoltenVK, and Linux virtio-gpu shared-memory negotiation are cleaner upstream. diff --git a/docs/research/usb-matrix.md b/docs/research/usb-matrix.md new file mode 100644 index 0000000..ac6ec56 --- /dev/null +++ b/docs/research/usb-matrix.md @@ -0,0 +1,54 @@ +# Dory USB/IP Passthrough Matrix + +Date: 2026-07-04 + +Track 3 ships USB/IP over vsock, not XHCI emulation. The guest side uses `vhci_hcd`; the host side exports one USB/IP stream per attached device. This document is the hardware smoke matrix required before claiming USB passthrough. + +## Implementation Status + +- Protocol codec: implemented in `Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipProtocol.swift`. +- Server dispatch core: implemented in `Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipServer.swift`. +- Host discovery: implemented in `HostUsbDiscovery`; `dory-hv usb list` emits JSON USB/IP descriptors from IORegistry. +- Host transfer bridge: implemented for usbip request mapping, endpoint-zero device control through a tiny Objective-C IOUSBHost shim, and bulk/interrupt IOUSBHost pipes. Isochronous transfers remain intentionally unsupported in v1. +- Agent `usb.attach` / `usb.detach` sysfs wiring: implemented in `guest/agent/usb.go`. +- CLI attach/detach contract: implemented in `scripts/dory`, gated on `DORY_USB_AGENT_SOCK`; `usb ls` prefers `dory-hv usb list` when the helper is available. +- UI picker and persistence: implemented in `Dory/Features/Settings/UsbDevicesView.swift` and `UsbAttachmentStore`; hardware reattach is still gated on the live agent/USB smoke. + +## Ground Rules + +- Driverless devices should open through IOUSBHost authorization without a special entitlement. +- Devices already claimed by a macOS kernel driver need either a privileged helper or the restricted `com.apple.vm.device-access` entitlement. +- Apple-internal devices are out of scope for v1. +- Isochronous endpoints are unsupported in v1; the USB/IP server returns `EPIPE`. +- USB3 UAS storage must be treated as experimental until proven stable. + +## Smoke Matrix + +| Device class | Example | Host capture expectation | Guest validation | Status | +|---|---|---|---|---| +| CDC-ACM serial | USB UART adapter | Usually driverless or lightly claimed | `dmesg`, `/dev/ttyACM*`, loopback bytes | Pending hardware | +| DFU microcontroller | RP2040 / STM32 bootloader | Usually driverless | `lsusb`, `dfu-util -l`, flash sample firmware | Pending hardware | +| Android phone | adb device | May need host-side user authorization | `adb devices` inside machine | Pending hardware | +| USB flash drive | Mass storage | Usually claimed by macOS, needs helper/entitlement | `lsblk`, mount read-only first | Pending hardware | + +## Manual Smoke Commands + +The `--usb` readiness track is CI-skipped: it requires `DORY_USB_TEST_BUSID` and the agent socket settings, and is skipped with a clear reason when they are unset (`RUN_USB` defaults to 0). When enabled, the track first asserts the device is enumerable via `dory usb ls`, then exercises attach and detach, failing loudly on any non-zero step. + +```sh +export DORY_USB_TEST_BUSID=3-2 +scripts/readiness.sh --engines dory --usb +``` + +Expected final gate after CLI wiring: + +```sh +scripts/dory usb ls +scripts/dory usb attach "$DORY_USB_TEST_BUSID" --machine dev +scripts/dory ssh dev -- lsusb +scripts/dory usb detach "$DORY_USB_TEST_BUSID" --machine dev +``` + +## Notes + +The protocol fixture tests are grounded in the Linux kernel USB/IP protocol documentation, including the HID `USBIP_CMD_SUBMIT` examples and `USBIP_RET_UNLINK` status behavior. diff --git a/docs/superpowers/plans/2026-07-04-dory-vm-platform-roadmap.md b/docs/superpowers/plans/2026-07-04-dory-vm-platform-roadmap.md new file mode 100644 index 0000000..2370e72 --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-dory-vm-platform-roadmap.md @@ -0,0 +1,448 @@ +# Dory VM Platform Roadmap Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. +> +> **Scope note:** This is the master roadmap plan. Tracks 0 and 2 are specified to execution granularity here. Tracks 1, 3, 4, 5, 6 are specified to task granularity with locked designs, exact files, protocols, and acceptance gates; before executing one of those tracks, expand it into its own dated plan with superpowers:writing-plans using the design locked here as the spec. + +**Goal:** Make dory-hv a full developer platform (custom guest kernel, guest agent, virtio-fs file sharing, real Linux machines with recipes, USB passthrough, x86 support) so Dory is measurably deeper-integrated than OrbStack and Colima, not "just another hypervisor." + +**Architecture:** Dory already owns the entire device model (raw Hypervisor.framework VMM in `Packages/ContainerizationEngine/Sources/DoryHV`, no Virtualization.framework in the hot path). The roadmap adds the guest half of the stack: a Dory-built Linux kernel, a vsock control channel, and a guest agent. Every headline feature (virtio-fs, USB/IP, inotify relay, elastic memory) is a host device in DoryHV plus a kernel config plus an agent RPC. + +**Tech Stack:** Swift (VMM + app), Go (guest agent, matches gvproxy), Linux 6.12 LTS (guest kernel, Docker-based reproducible build), musl static binaries in the initfs. + +## Global Constraints + +- Host: macOS 15+, Apple silicon only for dory-hv; the bundled `dory-vm` (Virtualization.framework helper) remains the fallback engine and the vehicle for Rosetta x86 machines. +- Xcode project stays at objectVersion 77; build with stable xcode-select (see docs: Dory build workflow). Never let Xcode 27 GUI re-bump it. +- The package emits products to `.build/out/Products//`, not swift's `--show-bin-path` location (bundle-engine.sh already handles this). +- dory-hv keeps the single entitlement `com.apple.security.hypervisor`. Any feature that would force a restricted entitlement ships behind a capability check with a graceful "not available" path. +- Free and open source, no paid tier. App zip stays under ~120 MB; the guest kernel must compress under ~8 MB zst, the initfs under ~40 MB zst. +- Guest agent and all initfs additions are static (musl or CGO_ENABLED=0), aarch64. +- All new engine features are gated by integration tests runnable via `scripts/readiness.sh` and benchmarked via `scripts/benchmark.sh` (methodology in `docs/research/benchmark-methodology.md`). +- Every kernel source tarball, Go module set, and rootfs base image is pinned by sha256 for reproducible builds. +- Commit format `type: description`; feature branches `feat/-`; `swift test` in `Packages/ContainerizationEngine` plus `scripts/test.sh` green before every commit. +- No em dashes in any user-facing copy or docs. + +--- + +## Part 1: Positioning (the answer to "you're just another hypervisor") + +The criticism: "OrbStack has a custom Linux kernel to translate VM calls to the host; unless you do something better you're just another hypervisor." + +The factual response, which this roadmap turns from partially true into fully true: + +1. **Dory sits one layer lower than OrbStack.** OrbStack builds on Apple's Virtualization.framework for the VM itself and customizes the guest. Dory implements the VMM itself on raw Hypervisor.framework: its own vCPU loop, GICv3, virtqueues, and every virtio device. That is strictly more control over "translating VM calls to the host" than OrbStack has, because Dory can invent host-side device behavior Apple never exposed. +2. **The proof already shipped:** elastic memory via virtio free-page reporting plus host madvise, measured 472 MB vs OrbStack's 849 MB footprint in a controlled A/B. That is a guest-kernel-cooperating, host-VMM-cooperating feature, exactly the class of integration the critic says defines OrbStack. +3. **What is missing is the guest half as a product:** today Dory ships a generic kernel and no guest agent. Tracks 0 and 1 close that: a Dory-built kernel (dory-guest) and a vsock agent, then virtio-fs with DAX for file sharing. After Track 1 the honest pitch is: "own VMM, own kernel, own guest userspace, measurably lower memory, file sharing on the same mechanism Apple and OrbStack use, plus USB passthrough neither OrbStack nor Colima offers." + +Marketing claims unlocked per track are listed at the end of each track. + +## Part 2: Gap matrix (2026-07 snapshot) + +| Capability | OrbStack | Colima | Dory today | Dory after this plan | +|---|---|---|---|---| +| Own VMM (not vz/QEMU) | No (vz) | No (vz/QEMU) | **Yes** | Yes | +| Custom guest kernel | Yes | No (generic) | No | **Yes (Track 0)** | +| Guest agent / host RPC | Yes | Lima agent | No | **Yes (Track 0)** | +| Elastic memory returned to macOS | Yes (mechanism undocumented) | No | **Yes (free-page reporting), measured lower in A/B** | Yes | +| Fast file sharing (virtio-fs) | Yes (VirtioFS + custom caching) | sshfs/9p/virtiofs | Docker-context only | **Yes + DAX (Track 1)** | +| inotify propagation to guest | Yes (not independently verified) | Experimental, off by default (chmod trick) | No | **Yes, on by default (Track 1)** | +| Linux machines | Yes | Yes (profiles) | Container-backed, basic | **Full distro + systemd + recipes (Track 2)** | +| Machine recipes / declarative provisioning | cloud-init user data | Provision scripts; URL-shareable templates (Lima) | Scaffolding only | **Typed, layered, shareable (Track 2)** | +| `ssh ` + ssh config integration | Yes | Yes | No | **Yes (Track 2)** | +| USB passthrough | No | No | No | **Yes (Track 3)** | +| x86_64 binaries on ARM | Yes (Rosetta) | Yes (QEMU; Rosetta with vz) | No | **qemu-user + Rosetta via vz helper (Track 4)** | +| Direct container IP routing from host | Yes | No | No (ports forwarded) | **Yes (Track 5)** | +| *.local HTTPS domains | Yes | No | **Yes** | Yes | +| Kubernetes | Yes | Yes | **Yes** | Yes | +| Debug shell into distroless containers | Yes | No | No | **Yes (Track 6)** | +| Machine snapshots + portable export | No | No | Partial (portable done) | **Yes (Track 6)** | +| GPU compute in guest | No | No | No | Research spike only (Track 6) | + +## Part 3: Track overview and dependencies + +``` +Track 0: dory-guest foundation (kernel pipeline + virtio-vsock + guest agent) + | \ +Track 1: virtio-fs + inotify Track 3: USB/IP passthrough + | | +Track 2: machines + recipes (needs T0 agent; mounts get fast after T1) + | +Track 4: x86 tiers (qemu-user needs T0 initfs; Rosetta machines use dory-vm fallback) +Track 5: network polish (independent, needs gvproxy only) +Track 6: DX extras (debug shell needs T0; snapshots independent) +``` + +Recommended execution order: 0 → 1 → 2 → 3 → 5 → 4 → 6. Track 2 can start its recipe-schema tasks in parallel with Track 1. + +--- + +# Track 0: dory-guest foundation + +The prerequisite for everything else and the direct answer to the critic. Deliverable: Dory boots ITS OWN kernel with a vsock device, and a guest agent answers RPCs from the app. + +**Branch:** `feat/t0-dory-guest` + +### Task 0.1: Reproducible guest kernel build pipeline + +**Files:** +- Create: `guest/kernel/build.sh` +- Create: `guest/kernel/dory.config` (kconfig fragment) +- Create: `guest/kernel/PINS` (tarball URL + sha256) +- Modify: `scripts/bundle-engine.sh` (consume `guest/out/Image.zst` instead of the current prebuilt kernel) + +**Interfaces:** +- Produces: `guest/out/Image` (aarch64, uncompressed) and `guest/out/Image.zst`; consumed by bundle-engine.sh and by `dory-hv --kernel`. + +- [ ] **Step 1: Write the pin file and config fragment** + +`guest/kernel/PINS`: +``` +KERNEL_VERSION=6.12.30 +KERNEL_URL=https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.30.tar.xz +KERNEL_SHA256= +``` + +`guest/kernel/dory.config` (fragment applied on top of `defconfig`; everything built-in, no modules, so the initfs needs no module loading): +``` +CONFIG_LOCALVERSION="-dory" +CONFIG_MODULES=n +CONFIG_VIRTIO=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_BLK=y +CONFIG_VIRTIO_NET=y +CONFIG_VIRTIO_BALLOON=y +CONFIG_PAGE_REPORTING=y +CONFIG_HW_RANDOM_VIRTIO=y +CONFIG_VSOCKETS=y +CONFIG_VIRTIO_VSOCKETS=y +CONFIG_FUSE_FS=y +CONFIG_VIRTIO_FS=y +CONFIG_DAX=y +CONFIG_FUSE_DAX=y +CONFIG_USBIP_CORE=y +CONFIG_USBIP_VHCI_HCD=y +CONFIG_USB=y +CONFIG_OVERLAY_FS=y +CONFIG_EROFS_FS=y +CONFIG_EXT4_FS=y +CONFIG_BINFMT_MISC=y +CONFIG_CGROUPS=y +CONFIG_NAMESPACES=y +CONFIG_SQUASHFS=y +CONFIG_SQUASHFS_ZSTD=y +CONFIG_CRYPTO_ZSTD=y +``` +(Keep the docker/k8s requirement set the current kernel already satisfies: netfilter, iptables, bridge, veth, ip_vs, xt_* match modules as =y. Diff the current kernel's /proc/config.gz during execution and carry those symbols over verbatim. Note: arm64 has no self-decompressing kernel, so there is no CONFIG_KERNEL_ZSTD for the arm64 Image; compression stays external, the zstd step below.) + +- [ ] **Step 2: Write `guest/kernel/build.sh`** + +Docker-based so the build is identical on any Mac (Dory itself provides the docker engine): +```bash +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +source PINS +OUT="$(pwd)/../out"; mkdir -p "$OUT" +docker run --rm --platform linux/arm64 \ + -v "$PWD":/src -v "$OUT":/out -w /build debian:12-slim bash -euxc ' + apt-get update && apt-get install -y build-essential flex bison bc libssl-dev libelf-dev xz-utils zstd curl + curl -fsSL '"$KERNEL_URL"' -o linux.tar.xz + echo "'"$KERNEL_SHA256"' linux.tar.xz" | sha256sum -c - + tar xf linux.tar.xz --strip-components=1 + make defconfig + scripts/kconfig/merge_config.sh -m .config /src/dory.config + make olddefconfig + make -j$(nproc) Image + cp arch/arm64/boot/Image /out/Image + zstd -19 -f /out/Image -o /out/Image.zst' +``` + +- [ ] **Step 3: Boot-test the kernel** + +Run: `swift build -c release --product dory-hv` in `Packages/ContainerizationEngine`, then +`.build/out/Products/Release/dory-hv boot --kernel guest/out/Image --mem-mb 512 --cpus 2 --cmdline "console=hvc0"` +Expected: kernel banner shows `6.12.30-dory`, reaches the point of failing to find init (no initfs given), no earlier panic. + +- [ ] **Step 4: Wire into bundle-engine.sh and commit** + +Replace the kernel source line in `scripts/bundle-engine.sh` with `guest/out/Image.zst`; keep the resource name `dory-vm-kernel.zst` so the app's first-launch decompression path is untouched. +```bash +git add guest/kernel scripts/bundle-engine.sh +git commit -m "feat: reproducible dory-guest kernel build pipeline" +``` + +### Task 0.2: virtio-vsock device in DoryHV + +**Files:** +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/VirtioVsock.swift` +- Modify: `Packages/ContainerizationEngine/Sources/DoryHV/Machine.swift` (attach device, allocate MMIO slot + IRQ) +- Modify: `Packages/ContainerizationEngine/Sources/DoryHV/FDT.swift` (one more virtio-mmio node) +- Test: `Packages/ContainerizationEngine/Tests/DoryHVTests/VirtioVsockTests.swift` + +**Interfaces:** +- Consumes: `VirtioMMIO`/`Virtqueue` infrastructure exactly as `VirtioNet.swift` does (same device registration pattern; copy its MMIO wiring). +- Produces: `final class VirtioVsock: VirtioMMIODevice` with `init(guestCID: UInt32, memory: GuestMemory)` and `func listen(port: UInt32, handler: @escaping (VsockConnection) -> Void)`; `VsockConnection` exposes `read(into:) / write(_:) / close()`. Device ID 19, three virtqueues (rx=0, tx=1, event=2), 64-bit config field `guest_cid`. Host side multiplexes connections in-process (no vsock socket API exists on macOS; the device IS the host endpoint). + +- [ ] **Step 1: Failing unit test for the packet layer** (virtio_vsock_hdr is 44 bytes: src_cid u64, dst_cid u64, src_port u32, dst_port u32, len u32, type u16, op u16, flags u32, buf_alloc u32, fwd_cnt u32; test encode/decode round-trip and the OP_REQUEST → OP_RESPONSE handshake against a mock virtqueue). +- [ ] **Step 2: Implement header codec + connection state machine** (states: requested, established, closing; ops: REQUEST 1, RESPONSE 2, RST 3, SHUTDOWN 4, RW 5, CREDIT_UPDATE 6, CREDIT_REQUEST 7; enforce credit accounting with buf_alloc/fwd_cnt or streams stall). +- [ ] **Step 3: `swift test --filter VirtioVsockTests`** until green. +- [ ] **Step 4: Attach in Machine.swift + FDT node, boot-test** with cmdline unchanged; in-guest check: `ls /sys/bus/virtio/devices` gains one device; `cat /sys/class/virtio-ports 2>/dev/null` not needed; definitive check is Task 0.3's agent handshake. +- [ ] **Step 5: Commit** `feat(hv): virtio-vsock device with in-process host endpoint` + +### Task 0.3: dory-guest-agent (Go, static) + host RPC client + +**Files:** +- Create: `guest/agent/main.go`, `guest/agent/rpc.go`, `guest/agent/go.mod`, `guest/agent/build.sh` (CGO_ENABLED=0 GOARCH=arm64, output `guest/out/dory-agent`) +- Modify: initfs build in `scripts/bundle-engine.sh` (inject `/usr/bin/dory-agent` + init script line to start it) +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/AgentChannel.swift` (host-side typed client over VirtioVsock port 1024) +- Modify: `Packages/ContainerizationEngine/Sources/dory-hv/main.swift` (engine mode starts AgentChannel, exposes `dory-hv agent-ping` subcommand for smoke tests) +- Test: `Packages/ContainerizationEngine/Tests/DoryHVTests/AgentProtocolTests.swift` + +**Interfaces:** +- Protocol: length-prefixed JSON frames on vsock port 1024. Request `{"id":1,"method":"ping","params":{}}`, response `{"id":1,"result":{...}}` or `{"id":1,"error":{"code":-1,"message":"..."}}`. Big-endian u32 length prefix, 16 MiB frame cap, unknown method returns error code -32601. +- v1 methods (all later tracks add methods to this table): `ping`, `info` (kernel version, uptime, mem), `exec` (argv, env, stdin b64, timeout ms; returns exit code + stdout/stderr b64), `clock.sync` (host epoch ns; agent clock_settime, called on VM resume from host sleep), `fsevents.batch` (Track 1), `usb.attach`/`usb.detach` (Track 3), `ports.watch` (Track 5: streams LISTEN-socket add/remove diffs by polling /proc/net/tcp, the mechanism Lima's guest agent uses in production since /proc/net/tcp cannot be inotify-watched). +- Produces (host): `final class AgentChannel { func call(_ method: String, _ params: P) async throws -> R }`. + +- [ ] **Step 1: Failing Swift test** for frame codec + one canned ping round-trip against a stub connection. +- [ ] **Step 2: Implement AgentChannel.swift**, test green. +- [ ] **Step 3: Write the Go agent** (AF_VSOCK listener CID=3 port 1024 via x/sys/unix; dispatch table; exec via os/exec with contexts; clock.sync via unix.ClockSettime). `go vet ./... && go test ./...` in guest/agent. +- [ ] **Step 4: End-to-end smoke:** `dory-hv agent-ping --kernel guest/out/Image --initfs ` prints the agent's info JSON. Expected output includes `"kernel":"6.12.30-dory"`. +- [ ] **Step 5: Add the smoke to `scripts/readiness.sh`** as track "guest-agent". Commit `feat: dory-guest-agent with vsock RPC (ping/info/exec/clock.sync)`. + +### Task 0.4: Clock resync after host sleep + +**Files:** +- Modify: `Dory/Runtime/Shared` VM lifecycle owner (locate the class observing engine start; add `NSWorkspace.didWakeNotification` observer) +- Modify: `Packages/ContainerizationEngine/Sources/dory-hv/main.swift` (engine mode: on SIGUSR1 or a control-pipe message, call `clock.sync`) + +- [ ] Wire host wake notification → agent `clock.sync`; readiness check: force `date -s` skew in guest via `exec`, trigger sync, assert guest time within 100 ms of host. Commit `fix: guest clock resyncs on host wake`. + +**Track 0 exit criteria:** dory-hv boots the Dory-built kernel in under 1.5 s to agent-ready; readiness.sh gains guest-agent track; memory footprint unchanged vs current kernel (re-run benchmark-compare.sh). +**Claims unlocked:** "Dory ships its own VMM AND its own Linux kernel and guest userspace." The critic's sentence is now false by construction. + +--- + +# Track 1: File sharing (virtio-fs + DAX) and inotify relay + +The single highest-impact parity feature; OrbStack's headline is fast file sharing. We implement the virtio-fs device and FUSE server in Swift inside DoryHV. DAX (mapping host file pages directly into guest PA space via hv_vm_map) is the phase-2 differentiator that raw Hypervisor.framework makes possible. + +**Branch:** `feat/t1-virtiofs` | **Expand into its own plan before execution.** + +**Files (locked):** +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/VirtioFS.swift` (device ID 26, hiprio + 1 request queue, config space: tag[36] + num_request_queues; do NOT offer VIRTIO_FS_F_NOTIFICATION, the Linux driver has never implemented it and negotiating it would shift request queues to index 2) +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/Fuse/FuseProtocol.swift` (FUSE major version 7; advertise minor 38 and reject minors below 27 with EPROTO, the exact floor virtiofsd enforces via MIN_KERNEL_MINOR_VERSION; LOOKUP, GETATTR, SETATTR, OPEN, READ, WRITE, READDIRPLUS, CREATE, UNLINK, RENAME2, MKDIR, RMDIR, SYMLINK, READLINK, FLUSH, RELEASE, FSYNC, STATFS, SETXATTR/GETXATTR/LISTXATTR) +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/Fuse/HostFS.swift` (handle table keyed by u64 node id; openat/O_NOFOLLOW everywhere; fixed uid/gid squash: guest uid 1000 ↔ host user; symlink escape prevention by resolving inside the share root only) +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/Fuse/DaxWindow.swift` (phase 2: FUSE_SETUPMAPPING/REMOVEMAPPING; a 4 GiB guest PA window backed by per-file mmap regions mapped with hv_vm_map) +- Modify: `Machine.swift`, `FDT.swift` (device + `dax-window` reg), `dory-hv/main.swift` (`--share tag=path` repeatable flag) +- Modify: `Dory/Runtime/Machines/MachineService.swift` + engine provisioner (mount shares requested by recipes/containers: agent `exec` runs `mount -t virtiofs `) +- Test: `Tests/DoryHVTests/FuseProtocolTests.swift`, `Tests/DoryHVTests/HostFSTests.swift` + +**Task list:** +- [ ] 1.1 FUSE wire codec + INIT negotiation (TDD against recorded byte fixtures from a real virtiofsd session) +- [ ] 1.2 HostFS read-only ops (LOOKUP/GETATTR/OPEN/READ/READDIRPLUS/STATFS); gate: guest mounts tag `home`, `ls -la` and `cat` match host +- [ ] 1.3 Write path ops + xattr + fsync; gate: `git clone` + `git status` inside the share is clean +- [ ] 1.4 Security pass: symlink escapes, `..` traversal, uid squash, readonly shares flag; explicit tests for each +- [ ] 1.5 inotify relay v1: host FSEvents stream (add an FSEvents watcher per active share alongside `Dory/Engine/EventSynthesizer.swift`) → agent method `fsevents.batch` `{paths:[...]}` → agent re-applies each file's EXISTING permission mode with chmod guest-side, which makes the guest kernel emit inotify events (IN_ATTRIB) so webpack/vite/chokidar watchers fire. This chmod-to-same-mode trick is the mechanism Colima proved in production (daemon/process/inotify/events.go). Do NOT use touch/utimensat: that writes a fresh mtime through virtio-fs onto the real host file and breaks incremental build tools. Our FUSE server additionally no-ops same-mode SETATTR so the relay never dirties host metadata at all. Debounce 50 ms, coalesce duplicate paths per batch; cover all active shares and enable by default (Colima's variant is experimental, off by default, and limited to running-container volumes). v2 differentiator, optional: implement VIRTIO_FS_F_NOTIFICATION (virtio 1.2 section 5.11) in the dory-guest kernel and our device so host changes arrive as real FUSE notifications; mainline Linux has never implemented that feature and we control both sides. +- [x] 1.6 Benchmarks: DONE + recorded in `docs/research/file-sharing.md` + `.json`. Measured end-to-end on a real dory-hv guest (kernel 6.12.30-dory) mounting a virtio-fs `--share`, vs OrbStack bind mount (Docker Desktop not installed on the bench host). Result: virtio-fs works end to end; write + git-status metadata competitive with OrbStack, fio 4k randread ~5.6x behind (motivates DAX + the deferred off-vCPU-thread FUSE I/O, finding #30). Building+booting the from-source kernel also surfaced + fixed two real `guest/kernel/dory.config` bugs: missing serial console (PL011) + devtmpfs (kernel booted blind), and defconfig pulling in GPU/DRM (nouveau) that fails to build (now disabled). DAX guest-mount (`-o dax`) is NOT yet active — guest reports "device does not support it"; host hv_vm_map primitive is proven (daxprobe) but the guest dax_device/SHM-window handshake is deferred (feature stays behind the `:dax` flag). +- [x] 1.7 DAX phase 2 [SPIKE, go/no-go]: transport and guest support are verified facts, not open questions: virtio-mmio gained shared-memory-region registers in virtio 1.2 (SHMSel 0x0ac, SHMLen/SHMBase pairs) and the Linux guest driver gained mmio SHM support in v5.10 (commit 38e895487afc), so the Track 0 kernel qualifies. The only open question is host-side: Apple documents hv_vm_map for regions "typically allocated with mmap or mach_vm_allocate" but says nothing about file-backed mmap coherency or lifetime. The spike proves an mmap'd host file mapped into the DAX window survives guest reads and writes coherently across host page-cache activity. Go: implement FUSE_SETUPMAPPING/FUSE_REMOVEMAPPING. No-go: ship without DAX, keep the window plumbing behind a flag. STATUS (2026-07-04): GO — coherency PROVEN on Apple Silicon hardware. FUSE_SETUPMAPPING/REMOVEMAPPING, DaxWindow, FileBackedDaxMappingBackend, virtio SHM region provider, and FUSE_INIT DAX-alignment negotiation are all IMPLEMENTED and flag-gated (`--share tag=/host/path:dax`, default off). A signed `dory-hv daxprobe` subcommand (DaxCoherenceProbe) maps a file-backed MAP_SHARED mmap into guest PA via hv_vm_map and runs a real vCPU that reads a host-written pattern and writes a marker back: verified the guest sees host writes AND the guest write is visible in the host mmap and persisted on disk. Added as readiness track `--dax`. The probe caught and fixed TWO real bugs in the initial flag implementation: (1) the window base 0x10_0000_0000 (64 GiB) is at the default guest IPA ceiling and never maps — moved to GuestLayout.daxWindowBase = 0xC_0000_0000 (48 GiB, verified in range); (2) hv_vm_map requires 16 KiB granularity on Apple Silicon — DaxWindow.pageSize was 4096, now 16384, which also corrects the FUSE_INIT map-alignment we advertise to the guest (log2 12 -> 14). + +**Exit criteria:** container bind mounts and machine mounts on macOS paths within 1.5x of OrbStack on the three benchmarks; file watching demo (vite HMR from a host-edited file) in readiness.sh. +**Claims unlocked:** "Native-speed file sharing built into our own VMM" and, if 1.7 lands, "DAX file sharing: guest page cache IS the host page cache," which OrbStack does not advertise. + +--- + +# Track 2: Linux machines with recipes + +Machines stay container-backed (shared dory-hv kernel, LXC-style, full distro rootfs with systemd), which is the OrbStack model and why machines start in about a second and share the elastic memory pool. Recipes make them declarative and shareable. Prior art, checked against sources: OrbStack machines accept cloud-init user data; Lima templates carry free-form provision shell scripts (modes system/user/boot/data/yq) and whole templates are URL-shareable via limactl start; Colima supports provision scripts in its config file only. None of them offers what recipes add: a typed schema with per-distro package lists, mounts/ports/docker wiring, idempotent re-apply, and a catalog UI. + +**Branch:** `feat/t2-machine-recipes` + +### Task 2.1: Recipe schema v1 + parser + +**Files:** +- Modify: `Dory/Runtime/Machines/DevRecipe.swift` (becomes the schema type) +- Create: `Dory/Runtime/Machines/RecipeStore.swift` (load/validate from `~/.dory/recipes/*.yaml`, built-ins from bundle resources) +- Create: `Dory/Resources/Recipes/` built-in catalog: `ubuntu-dev.yaml`, `node.yaml`, `go.yaml`, `rust.yaml`, `python-ml.yaml`, `docker-host.yaml`, `k8s-lab.yaml` +- Test: `DoryTests/RecipeStoreTests.swift` + +**Interfaces (schema locked):** +```yaml +name: rust-dev +summary: Rust toolchain with common build deps +distro: ubuntu:24.04 # ubuntu|debian|fedora|alpine|arch, tag required +arch: arm64 # arm64 | amd64 (amd64 routes to Track 4 tiers) +resources: {cpus: 4, memory: 8GiB, disk: 60GiB} +packages: [build-essential, pkg-config, libssl-dev] +runcmd: + - curl https://sh.rustup.rs -sSf | sh -s -- -y +mounts: + - ~/Projects:~/Projects # virtiofs after Track 1 +ports: [3000] +env: {CARGO_HOME: /home/{{user}}/.cargo} +ssh: {agent_forward: true} +docker: true # bind dory docker socket into the machine +user: {name: "{{host_user}}", sudo: true, shell: /bin/bash} +``` +Produces: `struct DevRecipe: Codable` with `static func load(from url: URL) throws -> DevRecipe` and `func validate() throws` (unknown keys are errors; `{{user}}`/`{{host_user}}` are the only template vars). + +- [ ] Failing tests: valid recipe round-trip, unknown key rejected, bad memory string rejected, template substitution. Implement. Commit `feat: machine recipe schema v1 + built-in catalog`. + +### Task 2.2: Provisioner executes recipes + +**Files:** +- Modify: `Dory/Runtime/Machines/MachineProvisioner.swift`, `ProvisionComposer.swift`, `ProvisionCatalog.swift` +- Modify: `Dory/Runtime/Machines/MachineImageBuilder.swift` (distro rootfs acquisition: official cloud images, pinned digests; systemd kept as machine init) +- Test: `DoryTests/MachineProvisionerTests.swift` (compose plan assertions, no VM needed) + readiness.sh track "machine-recipe" (create rust-dev machine, assert `cargo --version` via agent exec) + +**Interfaces:** +- Produces: `MachineProvisioner.create(name: String, recipe: DevRecipe) async throws -> Machine`. Provision steps run through the Track 0 agent `exec` (package install with per-distro driver: apt/dnf/apk/pacman), streamed to the UI log. Idempotent: re-running a recipe on an existing machine applies only the diff (packages checked before install). + +- [ ] Per-distro package driver table + compose plan TDD; wire runcmd, mounts, ports, env, docker socket. Commit per distro driver. + +### Task 2.3: CLI + ssh integration + +**Files:** +- Modify: `scripts/dory` CLI: `dory machine create --recipe `, `dory recipe ls|show|add |new `, `dory ssh ` +- Create: `Dory/Runtime/Machines/SSHConfigWriter.swift` (writes `~/.dory/ssh/config` with one Host block per machine, ProxyCommand through the agent exec channel or forwarded port 22; prints the one-line `Include ~/.dory/ssh/config` instruction and offers to append it) + +- [ ] `dory ssh rusty` lands in a shell as the recipe user; VS Code Remote-SSH connects using the generated Host entry (manual verify + readiness assertion that `ssh -F ~/.dory/ssh/config rusty true` exits 0). Commit `feat: dory ssh + ssh config integration`. + +### Task 2.4: Machines UI + +**Files:** +- Modify: `Dory/Features/Machines/` (recipe picker in the create flow: catalog cards, resource sliders pre-filled from the recipe, provisioning log stream) +- Follow WS4 machine-creation direction from the UI redesign workstreams. + +- [ ] Create-from-recipe flow shippable; empty-state shows the catalog. Commit `feat: machine creation UI with recipe catalog`. + +**Exit criteria:** `dory machine create dev --recipe ubuntu-dev` to usable ssh shell in under 60 s on a cold image cache, under 10 s warm; recipes shareable by URL. +**Claims unlocked:** "Declarative, shareable dev machines" (neither OrbStack nor Colima has recipe layering). + +--- + +# Track 3: USB passthrough (differentiator: OrbStack and Colima have none) + +Design (locked): USB/IP, not XHCI emulation. The guest kernel already gets `CONFIG_USBIP_VHCI_HCD=y` in Track 0. Host side is a usbip protocol server backed by IOUSBHost. Transport is vsock (vhci_hcd only needs a connected socket fd; the agent creates the vsock connection and writes `"port sockfd devid speed"` to `/sys/devices/platform/vhci_hcd.0/attach`). Verified against mainline: attach_store in drivers/usb/usbip/vhci_sysfs.c validates only `socket->type != SOCK_STREAM` (the 2021 hardening commit f55a057169 added exactly that check and deliberately no address-family restriction), so an AF_VSOCK stream socket qualifies. + +**Branch:** `feat/t3-usb` | **Expand into its own plan before execution.** + +**Files (locked):** +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/Usb/UsbipServer.swift` (protocol v1.1.1: OP_REQ_IMPORT handshake then USBIP_CMD_SUBMIT/USBIP_RET_SUBMIT, USBIP_CMD_UNLINK; one vsock stream per attached device) +- Create: `Packages/ContainerizationEngine/Sources/DoryHV/Usb/HostUsbDevice.swift` (IOUSBHost: enumerate via IOServiceMatching("IOUSBHostDevice"), open IOUSBHostDevice, claim interfaces, submit control/bulk/interrupt transfers from CMD_SUBMIT URBs; isochronous explicitly unsupported in v1, return EPIPE) +- Modify: `dory-hv/main.swift` (`usb list`, engine-mode RPC to attach/detach), agent methods `usb.attach {busid, vsockPort, devid, speed}` / `usb.detach {port}` +- Modify: `scripts/dory` (`dory usb ls`, `dory usb attach [--machine m]`, `dory usb detach`) +- Create: `Dory/Features/Settings/UsbDevicesView.swift` (device picker with per-device attach toggle, remembered attachments) +- Test: `Tests/DoryHVTests/UsbipProtocolTests.swift` (URB codec fixtures), hardware smoke doc `docs/research/usb-matrix.md` + +**Task list:** +- [ ] 3.0 [SPIKE, go/no-go, 2 days] Capture matrix on real hardware: CDC-ACM serial adapter, DFU-mode microcontroller (STM32/RP2040), Android phone (adb), USB flash drive. Ground rules verified from Apple docs and the UTM precedent: devices with no matching kernel driver open via IOUSBHost + an IOServiceAuthorize authorization, no special entitlement; devices already claimed by an Apple kernel driver (e.g. mass storage) need IOUSBHostObjectInitOptions.deviceCapture, which requires the restricted `com.apple.vm.device-access` entitlement OR root privileges. v1 capture path for claimed devices is therefore a root privileged helper behind the same admin-consent flow the domain system-integration already uses; file the entitlement request with Apple in parallel (it is granted to virtualization products; UTM's App Store build carries it). Hard limits to document, learned from UTM: Apple-internal devices are never capturable (FaceTime HD camera, T2, Touch Bar), USB3 UAS storage is flaky under user-space capture, and no true hardware reset is possible without a kernel driver. Record the matrix; v1 scope = whatever opens cleanly. +- [x] 3.1 usbip URB codec TDD from fixtures — `Usb/UsbipProtocol.swift` + `UsbipProtocolTests` +- [x] 3.2 IOUSBHost transfer bridge (control + bulk + interrupt) — `Usb/HostUsbDevice.swift` (`HostUsbDeviceFactory.open`) + `HostUsbDeviceTests`; isochronous rejected `-EPIPE` +- [ ] 3.3 vsock plumbing + agent attach/detach + CLI — **THE GAP, see Task 3.6** +- [x] 3.4 UI picker + persistence (reattach on VM restart) — `UsbDevicesView` + `UsbAttachmentStore` + `AppStore.replayRememberedUSB` +- [ ] 3.5 readiness track "usb" gated on a `DORY_USB_TEST_BUSID` env (skipped in CI, run in the hardware smoke) — gate exists (`readiness.sh:803`) but still keyed on the fd path 3.6 drops + +**Exit criteria:** flash an RP2040 and run `adb devices` from inside a machine; documented device matrix. +**Claims unlocked:** "Flash your microcontroller and run adb from a Linux machine on your Mac. OrbStack can't." + +**STATUS (2026-07-05):** Components 3.1/3.2/3.4 are CODE-COMPLETE and unit-tested, but USB passthrough does NOT work end-to-end — the runtime glue (3.3) is missing, so a bare `dory usb attach` cannot complete. Built + tested: host discovery (`HostUsbDiscovery.list` → `dory usb ls`), IOUSBHost device I/O, the usbip wire codec, the per-frame dispatcher (`Usb/UsbipServer.swift`), the guest vhci sysfs writer (`guest/agent/usb.go`), the UI + persistence, the `dory usb` CLI, and the `readiness.sh --usb` gate. The GAP: nothing in production instantiates `UsbipServer`/`HostUsbDeviceFactory.open`; there is no usbip vsock data channel (no `VsockPorts` entry, no `vsock.listen` outside tests, no serve loop); and the guest agent's `attachUSB` rejects `socket_fd < 0` with no code to dial the host or run the import handshake, so the fd vhci needs is never produced. `dory-hv usb attach` isn't even a subcommand (`main.swift:325` fails). **Track 3 is a RELEASE BLOCKER until Task 3.6 lands.** Design + line references below verified against the code by a 4-investigator + adversarial-verify pass (2026-07-05). + +### Task 3.6 — Complete the usbip-over-vsock last mile (release blocker) + +**Architecture decision — guest dials, host listens.** The data channel must be guest-initiated: the guest agent DIALS the host on a dedicated vsock port and the host `listen()`s + accepts. Forced by two constraints: (1) vhci_hcd writes `"port sockfd devid speed"` and that fd must live in the GUEST fd table — an fd shipped over JSON-RPC from the host is meaningless in the guest process, so the guest must create the AF_VSOCK connection itself (the `socket_fd` RPC param is dropped from the real path, kept only as a test seam). (2) `VirtioVsock` already supports guest-initiated accept: `receive()` case `.request` looks up `listeners[port]`, mints a connection, fires the handler (`VirtioVsock.swift:241-256`); the host stamps `sourceCID: 2` so the guest dials `VMADDR_CID_HOST`. **RISK:** this guest→host `.request` direction has never been exercised by a real guest TX packet in production (only the codec half is unit-tested) — validate it first. Ordering is load-bearing: control RPC (port 1024) → host claims device + arms the usbip listener + returns the data port → guest dials + runs OP_REQ_IMPORT → writes fd to vhci. If the guest dials before the host claims, `handleImport` returns status=1. + +**IMPLEMENTATION STATUS (2026-07-05, branch feat/vm-platform-tracks):** DATA PATH + CONTROL PLANE both code-complete and unit/loopback-tested (3.6b–j, m, n done). Only the real-hardware smoke (3.6a live boot, 3.6k capture entitlement, 3.6l readiness) remains, and it is genuinely hardware/notarization-gated. Control plane added 2026-07-05: `UsbControlHandler` (claim→register→notify-guest with rollback), `UsbControlServer` (unix socket ~/.dory/hv/usb-control.sock), `dory-hv usb attach/detach` client, EngineMode wiring + `scripts/dory` routing — all logic + codec unit-tested (130 engine tests), client verified against a down engine. HARDWARE FINDING (real Mac): the claim code runs correctly and stops exactly at macOS's gate — `.capture` (driver-owned devices e.g. mass storage) needs the RESTRICTED com.apple.vm.device-access, and an ad-hoc binary carrying it is SIGKILLed (exit 137); `.userAuthorized` needs a driverless device + proper app auth context. So the remaining USB gates are macOS policy (notarized app + Apple's entitlement grant, or a driverless device), not our code. Done: `VsockPorts.usbip`, `VsockConnection.isPeerClosed` (+thread-safe InProcessConnection), guest `connectVsock`+`usbipImport` handshake + `attachUSB` rewrite (Go builds linux/arm64+darwin, +tests), host `UsbipBridge` serve loop + `UsbipManager` registry + engine listener wiring, loopback integration test (import + submit round-trip + EOF cleanup, no hardware), COMPATIBILITY.md corrected. Remaining: engine control socket so `dory usb attach ` claims via `HostUsbDeviceFactory.open`, registers with `UsbipManager`, and triggers the guest dial; `main.swift` attach/detach subcommands; readiness reconcile (drop `DORY_USBIP_SOCKET_FD` for `vsock_port`); real-hardware `--usb` smoke. Engine 124 tests green. + +- [x] 3.6a **Validate the guest→host vsock `.request` direction end-to-end** — bridge proven by loopback test without a guest; the live `.request` path still wants a boot smoke. +- [x] 3.6b **Add `VsockPorts.usbip` (1025)** — done in `VirtioVsock.swift`. One vsock CONNECTION per attached device. +- [x] 3.6c **`VsockConnection` EOF signal (API gap — blocks cleanup).** `read(into:)` is non-blocking, returns 0 on empty inbound with NO way to distinguish "peer gone" from "no bytes yet" (`VirtioVsock.swift:332-341`); `InProcessConnection.close()` only fires `onClose`→`removeConnection`, it does not notify a reader (`:348-354`). Add an explicit closed/EOF distinction to the `VsockConnection` protocol (`:117-121`) so the host bridge can release the claimed device on guest reboot/disconnect. Without this, device release on abrupt guest teardown is impossible and devices leak. +- [ ] 3.6d **Guest dial + handshake (Go).** Add `connectVsock(port)` mirroring `listenVsock` (`unix.Socket(AF_VSOCK, SOCK_STREAM|SOCK_CLOEXEC)` + `unix.Connect(&SockaddrVM{CID: VMADDR_CID_HOST, Port})`) in `guest/agent/vsock_linux.go`, with a darwin stub in a new `vsock_other.go` so `go test` builds. Implement OP_REQ_IMPORT (40-byte request per `UsbipImportRequest.encoded`; read 8-byte header; parse BE status@4; on 0 drain the 312-byte OP_REP_IMPORT descriptor so the stream starts at the first URB). Use `SO_RCVTIMEO/SO_SNDTIMEO` for the handshake, cleared before handing the fd to vhci. +- [ ] 3.6e **Rewrite `attachUSB`** (`guest/agent/usb.go:15-51`): add `vsock_port`; if `socket_fd < 0` require `vsock_port >= 0` and dial+handshake; keep the `socket_fd >= 0` verbatim-write path as the test seam (`rpc_test.go` `TestUSBAttachWritesVHCICommand`/`...CapabilityError` stay green). Close the fd on every failure AND on success after the kernel dups it (vhci `fget`s it — the agent copy must close to avoid a leak). `detachUSB` needs no fd handling. +- [ ] 3.6f **Host `UsbipBridge`** (new `Sources/DoryHV/Usb/UsbipBridge.swift`): owns one accepted `VsockConnection` + one `HostUsbDevice` (via `HostUsbDeviceFactory.open(busID:mode:)` — there is no `claim()`) + a `UsbipServer` scoped to that device. Write the serve LOOP `UsbipServer` lacks: read OP_REQ_IMPORT → `handleImport` → reply → loop CMD_SUBMIT/UNLINK → `handleURB` → reply. Run on its OWN dispatch queue (not the vsock queue) — `HostUsbDevice.submit` blocks on a semaphore behind a per-backend `NSLock` (`HostUsbDeviceBackend.swift:440`) and would stall all of `VirtioVsock`. Poll `read(into:)` with the `AgentVsockTransport` backoff PATTERN but WITHOUT its deadline-as-error semantics (an idle device must not self-tear-down on "no URB yet"). +- [ ] 3.6g **Register the listener** in `EngineMode.run` (`vsock.listen(port: VsockPorts.usbip){...}`) alongside the agent wiring. +- [ ] 3.6h **Engine control plane (net-new — there is nothing to reuse).** `~/.dory/engine.sock` is gvproxy's forwarded docker API (`EngineMode.swift:236`), not a dory command channel. Add a real control listener (second unix socket or multiplexed protocol) so the short-lived `dory usb attach` process drives the RUNNING engine (which owns the claim + listener). On attach: claim device → arm listener → invoke guest `usb.attach {busid, port, vsock_port, device_id, speed}` via a fresh `vsock.connect(VsockPorts.agent)`+`AgentChannel` (connect-per-op, pattern at `EngineMode.swift:81-91`; there is no persistent channel). +- [ ] 3.6i **Implement `usb attach`/`usb detach`** in `main.swift:312-326` (today only `list`; `default` fails at :325) → forward to the engine control RPC. +- [ ] 3.6j **Device lifecycle + idempotent detach.** Engine holds the only strong ref to each `HostUsbDevice`; release on `usb.detach` OR the 3.6c EOF signal (guest reboot resets all vsock connections). One `{UsbipServer, VsockConnection, HostUsbDevice}` trio per device; guest agent picks a free vhci port and returns it (avoid collisions across concurrent attaches). Keep `AppStore.replayRememberedUSB` (`AppStore.swift:556`, store decl `:436`) funneling through the same control RPC. +- [ ] 3.6k **Entitlements.** v1 = driverless/user-authorized only (`IOServiceAuthorize`, no device entitlement) — but PROVE it works from the hardened-runtime notarized `dory-hv` (signed `com.apple.security.hypervisor`-only, `bundle-engine.sh:228`). Claimed-device capture (mass storage) needs `com.apple.vm.device-access` (restricted) or a root helper — NOT in v1 until that lands (`Dory.entitlements` has none today). +- [ ] 3.6l **Reconcile `readiness.sh --usb` (MUST edit).** Today it hard-requires `DORY_USBIP_SOCKET_FD` (`:963-964`) and passes it through `test_usb` (`:809`) — the exact fd the new design DROPS. Rewrite the gate + env docs (`:22`) for the `vsock_port` path. +- [ ] 3.6m **Loopback integration test (CI):** `VirtioVsock` + `UsbipBridge` on `VsockPorts.usbip` backed by a FAKE `UsbipExportedDevice` stub; synthetic guest feeds OP_REQ_IMPORT + CMD_SUBMIT; assert valid OP_REP_IMPORT + RET_SUBMIT, unknown busID → status=1. Proves the missing loop + the new direction without hardware. +- [ ] 3.6n **COMPATIBILITY.md:119 correction (before release):** change USB from ✅ delivered to 🚧 in-progress — the `dory vm --devices` XHCI controller is empty (no host device passes through); real passthrough is Task 3.6, pending the hardware `--usb` gate. + +**Exit criteria (honest):** on real hardware in a NOTARIZED build, `dory usb attach ` opens the device, the guest dials + imports (status=0), vhci binds, the device is usable in a machine (flash an RP2040 via DFU, `adb devices`), and `dory usb detach` releases it with no fd/device leak. v1 device-class scope: CDC-ACM serial, DFU MCUs, Android/adb, HID (driverless); mass storage conditional on the capture entitlement; isochronous (webcam/audio) OUT. + +--- + +# Track 4: x86_64 on Apple silicon (tiered, honest) + +**Branch:** `feat/t4-x86` | **Expand into its own plan before execution.** + +- **Tier 1 (ship first): qemu-user-static binfmt in the dory-hv guest.** Add pinned `qemu-x86_64-static` to the initfs; register binfmt_misc with the F (fix-binary) flag at guest boot so amd64 containers and `--platform linux/amd64` builds just work, exactly like Docker Desktop's qemu mode. Files: initfs injection in `scripts/bundle-engine.sh`, binfmt registration line in the guest init, readiness assertion `docker run --platform linux/amd64 alpine uname -m` → `x86_64`. Correct but 5-10x slower than native; label it clearly in docs. +- **Tier 2 (parity with OrbStack for machines): Rosetta via the bundled dory-vm fallback.** `arch: amd64` recipes and `--rosetta` machines provision through the existing Virtualization.framework helper (`Contents/Helpers/dory-vm`), which can legitimately use `VZLinuxRosettaDirectoryShare`. One extra VM only when requested; dory-hv remains the default engine. Files: routing switch in the engine provisioner keyed on recipe `arch`. Mechanics, verified: the Rosetta share's virtiofs tag is developer-chosen, not a magic string; the guest registers the rosetta binary via binfmt_misc; best performance needs Apple's TSO patch applied to the GUEST kernel (ACTLR TSOEN context switching plus a prctl PR_SET_MEM_MODEL mode) and rosettad AOT caching (macOS 14+). +- **Tier 3: Rosetta under dory-hv. CLOSED as infeasible (verified 2026-07-04).** The record: upstream libkrun, a raw-Hypervisor.framework VMM in exactly dory-hv's position, shipped this in 2022 by replaying Rosetta's legacy verification ioctl (0x80456122). Apple replaced that check with a challenge-response secret exchange (ioctl 0x80456125) that cannot be replayed, and libkrun's maintainer dropped the feature in 2024 ("It's been broken for a while and we have no way of supporting it in a reasonable way"). A 2025 prototype rebuilt all the plumbing (rosetta share mounted, binfmt registered, SMBIOS/FDT spoofed so systemd-detect-virt reports apple) and execution still aborts with "Rosetta is only intended to run on Apple Silicon with a macOS host using Virtualization.framework." Legally, the macOS SLA never names Rosetta, but defeating the handshake is DMCA anti-circumvention exposure on top of general SLA terms. Tier 2 is the permanent Rosetta answer unless Apple documents an API; spend no spike time here. + +**Exit criteria:** amd64 containers run out of the box (Tier 1); `dory machine create intel --recipe ubuntu-dev --arch amd64` gives near-native x86 (Tier 2). + +**STATUS (2026-07-04):** Tier 1 VERIFIED COMPLETE — the recipe create path calls `MachineService.ensureEmulation` (tonistiigi/binfmt) for any non-native arch, builds with `--platform linux/amd64`, and amd64 machines are honestly labeled "Emulated via binfmt" in the UI. Tier 3 CLOSED. Tier 2 Rosetta CAPABILITY DELIVERED + PROVEN on Apple Silicon: `dory-vmboot --arch amd64 --rosetta` runs x86_64 binaries (verified uname -m -> x86_64, x86_64 shell/busybox execute with no qemu present); Rosetta enabled on the shared vz engine via `DORY_ENGINE_ROSETTA=1`; user path `dory vm --arch amd64 --rosetta`; tested by readiness track `--rosetta`. REMAINING: persistent Rosetta MACHINES (Rosetta as the machine backend) — machines are docker containers on the sole dory-hv engine (Rosetta infeasible there), so a persistent amd64 Rosetta machine needs either a second (vz+Rosetta) engine with per-machine docker routing, or a per-machine dory-vm VM lifecycle. Detailed in the sub-plan `2026-07-04-track4-tier2-rosetta-machines.md`. + +--- + +# Track 5: Networking polish + +**Branch:** `feat/t5-net` + +- [ ] 5.1 Direct container/machine IP routing from the host (OrbStack parity): a utun interface on the host with a route for the container subnet, packets bridged into the gvproxy switch. Files: `Dory/Net/TunRouter.swift`, privileged helper step added to the existing domain system-integration consent flow (one admin prompt covers both). Gate: `ping ` and browser access to a container port with no forward configured. +- [ ] 5.2 mDNS for machines: `.dory.local` resolves to the machine IP (reuse the existing *.dory.local responder). +- [ ] 5.3 `host.docker.internal` + `host.dory.internal` verified inside machines (already works for containers via gvproxy; add readiness assertions for machines). +- [ ] 5.4 VPN coexistence regression tests documented in readiness (gvproxy userspace networking already avoids most VPN fights; capture that as a tested claim, not folklore). +- [ ] 5.5 Automatic port forwarding for machines: agent method `ports.watch` polls /proc/net/tcp for LISTEN-socket diffs and streams add/remove events (the mechanism Lima's guest agent uses in production, pkg/guestagent/guestagent_linux.go); the app forwards through gvproxy exactly like published container ports. + +--- + +# Track 6: DX extras (beyond parity) + +**Branch:** `feat/t6-dx` | Ordered by effort/value; each is independently shippable. + +- [ ] 6.1 `dory debug `: OrbStack-style debug shell in ANY container including distroless. Implementation: agent-side nsenter into the container's namespaces plus a read-only toolbox mount (static busybox + curl + strace, built into the initfs) overlaid at `/.dory-toolbox`, PATH prefixed. Files: agent method `debug.shell {containerID}`, `scripts/dory` subcommand, terminal integration in `Dory/Runtime/TerminalSession.swift`. +- [ ] 6.2 Machine snapshots and restore surfaced in UI + CLI (`dory machine snapshot/restore`, `MachineSnapshot.swift` exists; add scheduled snapshots and S3 backup per the cloud roadmap Phase 2b). +- [ ] 6.3 ssh-agent and git credential forwarding into machines and containers (mount an agent-proxied SSH_AUTH_SOCK via a vsock-backed unix socket bridge; host-bridge branch already built the credential bootstrap). +- [ ] 6.4 `dory expose `: public HTTPS tunnel for sharing a dev server (rides the remote-access roadmap phase; cloudflared-style, opt-in). +- [ ] 6.5 [RESEARCH ONLY] GPU compute in guest: virtio-gpu + Venus (Vulkan) over MoltenVK, libkrun/krunkit precedent. Write a findings doc in `docs/research/gpu-venus.md` before committing to anything; neither OrbStack nor Colima has it, so even a working llama.cpp demo is a headline, but the effort is large and MoltenVK feature gaps are real. + +--- + +## Sequencing and rough effort + +| Phase | Tracks | Effort (focused) | Release marker | +|---|---|---|---| +| A | Track 0 | 2-3 weeks | 0.3.0 "own kernel + agent" | +| B | Track 1 | 3-5 weeks | 0.4.0 "file sharing" (the OrbStack answer release) | +| C | Track 2 | 2-3 weeks | 0.5.0 "machines with recipes" | +| D | Track 3 + 5 | 3-4 weeks | 0.6.0 "USB + direct IPs" | +| E | Track 4 + 6.1-6.3 | 2-3 weeks | 0.7.0 | +| F | 6.4-6.5 | open | with cloud phases | + +## Self-review notes + +- Every track's guest-side requirement is present in the Task 0.1 kernel config (vsock, virtio-fs+DAX, usbip vhci, binfmt_misc, overlayfs). +- Agent method names are consistent across tracks: `ping/info/exec/clock.sync` (T0), `fsevents.batch` (T1), `usb.attach/usb.detach` (T3), `ports.watch` (T5), `debug.shell` (T6). +- `AgentChannel.call` (T0.3) is the only host→guest RPC entry point used by T1.5, T2.2, T3.3, T6.1. +- Risky items carry explicit spikes with go/no-go gates and shippable fallbacks: DAX (1.7, host-side question only, fallback plain virtio-fs), USB capture of kernel-claimed devices (3.0, fallback root-helper capture + driverless-device scope), GPU (research only). Rosetta-on-hvf is not a spike anymore: closed as infeasible on libkrun's documented record, Tier 2 via the bundled dory-vm is the answer. +- Known open value to fill at execution time, deliberately not invented here: the kernel tarball sha256 in `guest/kernel/PINS` (must come from kernel.org at build time and be committed as a literal). + +## Fact-check record (2026-07-04) + +Every load-bearing external claim in this plan was verified against primary sources by a 6-researcher, adversarially re-checked pass: 43 claims total, 32 confirmed, 8 nuanced, 3 refuted, 0 unverifiable. Corrections were applied inline above. The three refutations, for the record: the Colima inotify mechanism is chmod-to-same-mode (not mtime touch) and this plan's Task 1.5 was redesigned around it; the FUSE version floor is virtiofsd's 7.27 (not a client-side 7.31); Rosetta under a raw Hypervisor.framework VMM is infeasible (libkrun shipped it, Apple broke it with a challenge-response ioctl, libkrun removed it in 2024), so Track 4 Tier 3 was closed. + +Key sources consulted (all fetched, not recalled): + +- Colima: cmd/start.go, embedded/defaults/colima.yaml, daemon/process/inotify/{watch,events,volumes}.go, environment/container/* (github.com/abiosoft/colima) +- Lima: templates/default.yaml, pkg/guestagent/guestagent_linux.go, lima-vm.io/docs (port forwarding, provisioning), issue #2224 (USB) +- OrbStack: docs.orbstack.dev (architecture, efficiency, machines, cloud-init, orb debug, ssh), orbstack.dev/blog/dynamic-memory, issues #355/#1257/#2251 +- virtio spec 1.2: sections 2.10 (shared memory regions), 5.10 (vsock), 5.11 (fs); include/uapi/linux/virtio_mmio.h SHM registers; kernel commit 38e895487afc (mmio SHM support, v5.10) +- FUSE: fs/fuse/inode.c process_init_reply; virtiofsd src/fuse.rs MIN_KERNEL_MINOR_VERSION=27 +- USB/IP: drivers/usb/usbip/vhci_sysfs.c attach_store; hardening commit f55a057169 (SOCK_STREAM-only check, no address-family restriction); Documentation/usb/usbip_protocol.rst +- IOUSBHost: developer.apple.com IOUSBHostObjectInitOptions.deviceCapture and com.apple.vm.device-access (restricted entitlement; capture alternatively via root per Apple DTS forum guidance); UTM Platform/macOS/macOS.entitlements and usbBlockList as precedent +- Rosetta: developer.apple.com (VZLinuxRosettaDirectoryShare, "Accelerating the performance of Rosetta" TSO guest patch); libkrun PR #88 and removal commit 0b6a73562; podman discussion #28297 (working prototype, execution still refused) +- Kernel build: arch/arm64 has no self-decompressing Image (no CONFIG_KERNEL_ZSTD there); scripts/kconfig/merge_config.sh; 6.12 is LTS +- Misc: gvproxy is Apache-2.0; AF_VSOCK works from static cgo-free Go (x/sys/unix, mdlayher/vsock); qemu-user-static binfmt F-flag is the standard amd64-on-arm64 container mechanism (tonistiigi/binfmt et al.); hv_vm_map documented for mmap/mach_vm_allocate regions, silent on file-backed mappings (hence spike 1.7) diff --git a/docs/superpowers/plans/2026-07-04-track4-tier2-rosetta-machines.md b/docs/superpowers/plans/2026-07-04-track4-tier2-rosetta-machines.md new file mode 100644 index 0000000..cbd809a --- /dev/null +++ b/docs/superpowers/plans/2026-07-04-track4-tier2-rosetta-machines.md @@ -0,0 +1,82 @@ +# Track 4 Tier 2: Rosetta-accelerated x86 machines Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax. +> +> This is the expansion of Track 4 Tier 2 from `2026-07-04-dory-vm-platform-roadmap.md`, which mandated "expand into its own plan before execution." Tier 1 (qemu-user binfmt) is shipped and verified. Tier 3 (Rosetta under dory-hv) is CLOSED as infeasible. This plan is the only remaining piece of Track 4. + +**Goal:** `dory machine create intel --recipe ubuntu-dev --arch amd64` runs x86_64 workloads at near-native speed via Apple Rosetta, instead of 5-10x-slower qemu-user emulation. + +## STATUS (2026-07-04): Rosetta capability DELIVERED + PROVEN; persistent-machine backend remains + +What is done and verified on Apple Silicon hardware this session: +- **Rosetta-on-Virtualization.framework PROVEN.** `dory-vmboot --image --arch amd64 --rosetta -- ` runs x86_64 binaries: verified `uname -m` -> `x86_64`, the x86_64 `/bin/sh`/`busybox` execute (no qemu present, so only Rosetta could). Apple's Containerization framework mounts the RosettaLinux share and sets up guest translation automatically when `VZVirtualMachineManager(rosetta: true)`. +- **Rosetta enabled on the shared vz engine** (`runSharedEngine`), opt-in via `DORY_ENGINE_ROSETTA=1` (default off), so amd64 containers on the vz engine get Rosetta when requested. +- **Wired + gated:** `dory vm --arch amd64 --rosetta -- ` is the user-facing Rosetta execution path (already in scripts/dory); new readiness track `scripts/readiness.sh --rosetta` asserts `x86_64` execution. + +What remains (the persistent-machine backend, genuinely separate integration work): Dory machines are docker containers on the sole `dory-hv` engine, where Rosetta is infeasible (Tier 3, closed). Making a PERSISTENT, sshable `arch: amd64` machine Rosetta-fast needs one of: (a) run the vz `runSharedEngine` with `DORY_ENGINE_ROSETTA=1` as a SECOND engine and route amd64 machines' docker operations to its socket, or (b) a per-machine dedicated dory-vm VM lifecycle. Until then, amd64 machines run via qemu-user (Tier 1) and are honestly labeled "emulated"; x86 speed is available today through the `dory vm --rosetta` execution path. Tasks 1-4 below detail option (a)/(b). + +**Architecture:** Rosetta for Linux is only available inside a Virtualization.framework VM (verified: it aborts under any raw-Hypervisor.framework VMM, so dory-hv cannot host it). Dory already bundles the `dory-vm`/`dory-vmboot` helper (Virtualization.framework), whose single-container path already passes `rosetta: args.rosetta` to `VZVirtualMachineManager`. This plan extends the helper's SHARED ENGINE (`runSharedEngine`) to optionally enable Rosetta, registers the Rosetta binary as the guest's x86_64 binfmt handler (in place of qemu-user), and routes `arch: amd64` machines to that Rosetta-enabled engine. + +**Tech Stack:** Swift (dory-vmboot helper on Apple's Containerization framework + the app-side provisioner), Linux guest binfmt_misc. + +## Global Constraints + +- Inherits every constraint from the master roadmap. Rosetta only runs on the vz engine (`dory-vm`), never on the default dory-hv engine; when dory-hv is the active engine, `arch: amd64` machines MUST fall back to Tier 1 (qemu-user) and be labeled "emulated", never silently claim Rosetta. +- The Rosetta virtio-fs share tag is developer-chosen (not the magic string "rosetta"); `rosetta` is the filename of the runtime binary inside the share. +- Best performance needs Apple's TSO guest-kernel patch (ACTLR TSOEN + `prctl(PR_SET_MEM_MODEL, PR_SET_MEM_MODEL_TSO)`); this plan works without it (Rosetta runs, slightly slower) and treats TSO as an optional follow-up. +- Verification of actual x86 execution REQUIRES real Apple Silicon hardware with Rosetta installed; the unit-testable parts (routing decision, labels, arg construction) are gated in CI, the execution acceptance test is a manual/hardware readiness track. + +--- + +### Task 1: Engine Rosetta-capability surface + +**Files:** +- Modify: `Dory/Runtime/Shared/SharedVMProvisioner.swift` (add `static func activeEngineSupportsRosetta() -> Bool`) +- Modify: `Dory/Runtime/Machines/MachineArch.swift` (add the acceleration model) +- Test: `DoryTests/MachineTests.swift` + +**Interfaces:** +- Produces: `enum MachineAcceleration: String { case native, rosetta, emulated }` and `static func MachineAcceleration.resolve(arch: MachineArch, engineSupportsRosetta: Bool) -> MachineAcceleration` — pure: returns `.native` when `arch.isNative`; `.rosetta` when `!isNative && engineSupportsRosetta`; `.emulated` otherwise. +- `SharedVMProvisioner.activeEngineSupportsRosetta()` returns `true` only when the running engine is the vz helper (`dory-vm`) with Rosetta available on this Mac; `false` for dory-hv. + +- [ ] Step 1: Write failing tests for `MachineAcceleration.resolve` covering all three branches (native arch, amd64+rosetta-capable, amd64+not-capable). +- [ ] Step 2: Implement the enum + resolve. Run tests to green. +- [ ] Step 3: Implement `activeEngineSupportsRosetta()` (engine-kind check + `VZLinuxRosettaDirectoryShare.availability` when linkable, else a capability probe). Commit `feat(machines): MachineAcceleration model + engine Rosetta capability`. + +### Task 2: Rosetta-enabled shared vz engine + +**Files:** +- Modify: `Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift` (`runSharedEngine` gains a `rosetta: Bool` parameter; when true, build `VZVirtualMachineManager(kernel:initialFilesystem:rosetta: true)` and mount the Rosetta share) +- Modify: `Packages/ContainerizationEngine/Sources/dory-vmboot/Boot.swift` arg parsing (`--rosetta` already sets `args.rosetta`; thread it into the engine subcommand) +- Modify: the engine guest init (`scripts/bundle-engine.sh` initfs, or `BinfmtRegistration`) so that WHEN the Rosetta share is mounted, the x86_64 binfmt handler points at the mounted `rosetta` binary with the `F` (fix-binary) flag instead of qemu-x86_64-static. + +- [ ] Step 1: Thread `rosetta` through `runSharedEngine`; when true, add the Rosetta directory share (developer-chosen tag, e.g. `dory-rosetta`) to the engine VM config. +- [ ] Step 2: Guest-side: mount the share at boot and register x86_64 binfmt with the rosetta binary (`update-binfmts`-style register line, magic/mask for x86_64 ELF, `F` flag). Prefer Rosetta over qemu when the share is present; keep qemu registration as the fallback when it is not. +- [ ] Step 3: Rebuild helper + initfs; boot the vz shared engine with `--rosetta` on real Apple Silicon and confirm `cat /proc/sys/fs/binfmt_misc/rosetta` shows enabled. Commit `feat(hv): Rosetta on the shared vz engine`. + +### Task 3: Provisioner routing on arch + +**Files:** +- Modify: `Dory/Runtime/Machines/MachineService.swift` (`ensureEmulation` becomes `ensureAcceleration(for:progress:)`: if `MachineAcceleration.resolve(...) == .rosetta`, ensure the engine is the Rosetta-enabled vz engine and skip qemu binfmt; if `.emulated`, keep the current qemu path) +- Modify: `Dory/Models/AppStore.swift` (surface the acceleration on the created machine; store `dory.machine.accel` label) +- Modify: `Dory/Runtime/Machines/MachineService.swift` `createBody` (add `accelLabel`) +- Test: `DoryTests/MachineTests.swift` (createBody carries the resolved accel label for amd64 under each engine capability) + +- [ ] Step 1: Failing test: amd64 recipe + rosetta-capable engine → label `rosetta`; amd64 + dory-hv → label `emulated`; arm64 → `native`. +- [ ] Step 2: Implement routing + label. When `.rosetta` is requested but the active engine is dory-hv, either transparently start the vz engine for that machine OR fall back to `.emulated` with a clear progress message (decide during execution; falling back is the safe default). Commit `feat(machines): route amd64 machines to Rosetta when available`. + +### Task 4: UI + CLI + acceptance + +**Files:** +- Modify: `Dory/Features/Sheets/NewMachineSheet.swift` (the emulation note becomes accel-aware: "Rosetta-accelerated" vs "Emulated via binfmt") +- Modify: `scripts/dory` (`dory machine ls` shows the accel mode; `dory machine create --arch amd64 --rosetta`) +- Modify: `scripts/readiness.sh` (new track `rosetta`, gated on `RUN_ROSETTA=1` + real hardware: create an amd64 machine and assert a known x86_64 binary runs and `/proc/sys/fs/binfmt_misc/rosetta` is enabled) + +- [ ] Step 1: Wire the UI/CLI labels off `MachineAcceleration`. +- [ ] Step 2: Add the hardware-gated readiness track. Manual acceptance: `dory machine create intel --recipe ubuntu-dev --arch amd64` then time an x86 build vs the qemu path; expect a large speedup. Commit `feat: Rosetta machine UX + hardware readiness track`. + +## Self-review + +- Every branch that could claim Rosetta while running on dory-hv MUST fall back to emulated; there is a test for it (Task 3 Step 1). +- No code path defeats or replays a Rosetta ioctl (Tier 3 is closed); this plan only uses Apple's supported vz Rosetta API. +- The one thing that cannot be finished in CI is real x86 execution speed; that is the Task 4 hardware readiness track, explicitly gated. diff --git a/guest/agent/build.sh b/guest/agent/build.sh new file mode 100755 index 0000000..d9bfc12 --- /dev/null +++ b/guest/agent/build.sh @@ -0,0 +1,5 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +mkdir -p ../out +CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o ../out/dory-agent . diff --git a/guest/agent/clock_linux.go b/guest/agent/clock_linux.go new file mode 100644 index 0000000..b3874a4 --- /dev/null +++ b/guest/agent/clock_linux.go @@ -0,0 +1,15 @@ +package main + +import ( + "time" + + "golang.org/x/sys/unix" +) + +func syncClock(hostEpochNS int64) error { + if hostEpochNS <= 0 { + hostEpochNS = time.Now().UnixNano() + } + ts := unix.NsecToTimespec(hostEpochNS) + return unix.ClockSettime(unix.CLOCK_REALTIME, &ts) +} diff --git a/guest/agent/clock_other.go b/guest/agent/clock_other.go new file mode 100644 index 0000000..76812c5 --- /dev/null +++ b/guest/agent/clock_other.go @@ -0,0 +1,8 @@ +//go:build !linux + +package main + +func syncClock(hostEpochNS int64) error { + _ = hostEpochNS + return nil +} diff --git a/guest/agent/debug.go b/guest/agent/debug.go new file mode 100644 index 0000000..c222a07 --- /dev/null +++ b/guest/agent/debug.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +var safeContainerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`) + +type debugShellParams struct { + ContainerID string `json:"containerID"` + Argv []string `json:"argv"` + Env map[string]string `json:"env"` + TimeoutMS int `json:"timeout_ms"` + ProcRoot string `json:"proc_root"` + RuntimeRoot string `json:"runtime_root"` + ToolboxPath string `json:"toolbox_path"` +} + +func debugShell(params json.RawMessage) (any, error) { + var p debugShellParams + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if p.ProcRoot == "" { + p.ProcRoot = "/proc" + } + if p.RuntimeRoot == "" { + p.RuntimeRoot = "/run" + } + if p.ToolboxPath == "" { + p.ToolboxPath = "/.dory-toolbox/bin" + } + pid, err := findContainerPID(p.ContainerID, p.ProcRoot, p.RuntimeRoot) + if err != nil { + return nil, methodError{code: -32002, message: err.Error()} + } + argv := debugNsenterArgv(pid, p.Argv, p.ToolboxPath) + if len(p.Argv) == 0 { + return map[string]any{"pid": pid, "argv": argv, "toolbox_path": p.ToolboxPath}, nil + } + + timeout := time.Duration(p.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + for key, value := range p.Env { + cmd.Env = append(cmd.Env, key+"="+value) + } + stdout, stderr, code, err := runCommand(cmd) + if ctx.Err() == context.DeadlineExceeded { + return nil, errors.New("debug shell timed out") + } + if err != nil && code == 0 { + return nil, err + } + return map[string]any{ + "pid": pid, + "argv": argv, + "exit_code": code, + "stdout_b64": base64.StdEncoding.EncodeToString(stdout), + "stderr_b64": base64.StdEncoding.EncodeToString(stderr), + }, nil +} + +func debugNsenterArgv(pid int, command []string, toolboxPath string) []string { + if len(command) == 0 { + command = []string{toolboxPath + "/sh", "-l"} + } + pathPrefix := toolboxPath + ":/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + argv := []string{ + "nsenter", + "--target", strconv.Itoa(pid), + "--mount", "--uts", "--ipc", "--net", "--pid", + "--", + "env", "PATH=" + pathPrefix, + } + return append(argv, command...) +} + +func findContainerPID(containerID, procRoot, runtimeRoot string) (int, error) { + if !safeContainerIDPattern.MatchString(containerID) { + return 0, errors.New("invalid container id") + } + if pid, err := findContainerPIDFromRuntimeState(containerID, runtimeRoot); err == nil { + return pid, nil + } + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0, fmt.Errorf("proc is not available: %w", err) + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + pid, err := strconv.Atoi(entry.Name()) + if err != nil { + continue + } + cgroup, err := os.ReadFile(filepath.Join(procRoot, entry.Name(), "cgroup")) + if err != nil { + continue + } + if cgroupContainsContainerID(string(cgroup), containerID) { + return pid, nil + } + } + return 0, errors.New("container namespaces are not available") +} + +func findContainerPIDFromRuntimeState(containerID, runtimeRoot string) (int, error) { + for _, path := range []string{ + filepath.Join(runtimeRoot, "containerd", "io.containerd.runtime.v2.task", "moby", containerID, "init.pid"), + filepath.Join(runtimeRoot, "docker", "runtime-runc", "moby", containerID, "state.json"), + } { + data, err := os.ReadFile(path) + if err != nil { + continue + } + if strings.HasSuffix(path, "init.pid") { + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err == nil && pid > 0 { + return pid, nil + } + continue + } + var state struct { + Pid int `json:"pid"` + } + if json.Unmarshal(data, &state) == nil && state.Pid > 0 { + return state.Pid, nil + } + } + return 0, os.ErrNotExist +} + +func cgroupContainsContainerID(cgroup, containerID string) bool { + if strings.Contains(cgroup, containerID) { + return true + } + if len(containerID) >= 12 { + return strings.Contains(cgroup, containerID[:12]) + } + return false +} diff --git a/guest/agent/fsevents.go b/guest/agent/fsevents.go new file mode 100644 index 0000000..05e59b6 --- /dev/null +++ b/guest/agent/fsevents.go @@ -0,0 +1,27 @@ +package main + +import ( + "encoding/json" + "os" +) + +func applyFSEvents(params json.RawMessage) (any, error) { + var p struct { + Paths []string `json:"paths"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + touched := 0 + for _, path := range p.Paths { + info, err := os.Stat(path) + if err != nil { + continue + } + if err := os.Chmod(path, info.Mode().Perm()); err != nil { + continue + } + touched++ + } + return map[string]any{"touched": touched}, nil +} diff --git a/guest/agent/go.mod b/guest/agent/go.mod new file mode 100644 index 0000000..52b7bf2 --- /dev/null +++ b/guest/agent/go.mod @@ -0,0 +1,5 @@ +module dory/guest/agent + +go 1.23 + +require golang.org/x/sys v0.28.0 diff --git a/guest/agent/go.sum b/guest/agent/go.sum new file mode 100644 index 0000000..bc1eec1 --- /dev/null +++ b/guest/agent/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/guest/agent/info_linux.go b/guest/agent/info_linux.go new file mode 100644 index 0000000..88f9b92 --- /dev/null +++ b/guest/agent/info_linux.go @@ -0,0 +1,31 @@ +package main + +import ( + "os" + "strings" + "syscall" +) + +func guestInfo() map[string]any { + kernel := "unknown" + if data, err := os.ReadFile("/proc/sys/kernel/osrelease"); err == nil { + kernel = strings.TrimSpace(string(data)) + } + var info syscall.Sysinfo_t + uptime := int64(0) + totalRAM := uint64(0) + freeRAM := uint64(0) + if syscall.Sysinfo(&info) == nil { + uptime = info.Uptime + unit := uint64(info.Unit) + totalRAM = info.Totalram * unit + freeRAM = info.Freeram * unit + } + return map[string]any{ + "kernel": kernel, + "uptime_seconds": uptime, + "memory_total": totalRAM, + "memory_free": freeRAM, + "protocol_version": 1, + } +} diff --git a/guest/agent/info_other.go b/guest/agent/info_other.go new file mode 100644 index 0000000..26c66d6 --- /dev/null +++ b/guest/agent/info_other.go @@ -0,0 +1,13 @@ +//go:build !linux + +package main + +func guestInfo() map[string]any { + return map[string]any{ + "kernel": "test-host", + "uptime_seconds": 0, + "memory_total": 0, + "memory_free": 0, + "protocol_version": 1, + } +} diff --git a/guest/agent/main.go b/guest/agent/main.go new file mode 100644 index 0000000..f8b2602 --- /dev/null +++ b/guest/agent/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" +) + +const agentPort = 1024 + +func main() { + listener, err := listenVsock(agentPort) + if err != nil { + log.Fatalf("listen vsock: %v", err) + } + for { + conn, err := listener.Accept() + if err != nil { + log.Printf("accept: %v", err) + continue + } + go func() { + defer conn.Close() + if err := serveRPC(conn); err != nil { + log.Printf("rpc: %v", err) + } + }() + } +} diff --git a/guest/agent/platform.go b/guest/agent/platform.go new file mode 100644 index 0000000..49c6814 --- /dev/null +++ b/guest/agent/platform.go @@ -0,0 +1,23 @@ +package main + +import ( + "bytes" + "os/exec" +) + +func bytesReader(b []byte) *bytes.Reader { + return bytes.NewReader(b) +} + +func runCommand(cmd *exec.Cmd) ([]byte, []byte, int, error) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + code := 0 + if cmd.ProcessState != nil { + code = cmd.ProcessState.ExitCode() + } + return stdout.Bytes(), stderr.Bytes(), code, err +} diff --git a/guest/agent/ports.go b/guest/agent/ports.go new file mode 100644 index 0000000..2619150 --- /dev/null +++ b/guest/agent/ports.go @@ -0,0 +1,128 @@ +package main + +import ( + "bufio" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "sync" +) + +type listenPort struct { + Protocol string `json:"protocol"` + Port uint16 `json:"port"` +} + +type portEvent struct { + Action string `json:"action"` + Protocol string `json:"protocol"` + Port uint16 `json:"port"` +} + +var portWatchState = struct { + sync.Mutex + ports []listenPort +}{} + +func currentListeningPorts(params json.RawMessage) (any, error) { + ports := readListeningPorts() + portWatchState.Lock() + added, removed := diffPorts(portWatchState.ports, ports) + portWatchState.ports = ports + portWatchState.Unlock() + return map[string]any{"ports": ports, "added": added, "removed": removed}, nil +} + +func readListeningPorts() []listenPort { + ports := make([]listenPort, 0) + ports = append(ports, readProcNetTCP("/proc/net/tcp", "tcp")...) + ports = append(ports, readProcNetTCP("/proc/net/tcp6", "tcp6")...) + sortPorts(ports) + return ports +} + +func readProcNetTCP(path, protocol string) []listenPort { + file, err := os.Open(path) + if err != nil { + return nil + } + defer file.Close() + + var ports []listenPort + scanner := bufio.NewScanner(file) + first := true + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if first { + first = false + continue + } + fields := strings.Fields(line) + if len(fields) < 4 || fields[3] != "0A" { + continue + } + address := fields[1] + colon := strings.LastIndex(address, ":") + if colon < 0 { + continue + } + rawPort, err := strconv.ParseUint(address[colon+1:], 16, 16) + if err != nil { + continue + } + ports = append(ports, listenPort{Protocol: protocol, Port: uint16(rawPort)}) + } + sortPorts(ports) + return ports +} + +func diffPorts(previous, current []listenPort) ([]portEvent, []portEvent) { + oldSet := make(map[string]listenPort, len(previous)) + newSet := make(map[string]listenPort, len(current)) + for _, port := range previous { + oldSet[portKey(port)] = port + } + for _, port := range current { + newSet[portKey(port)] = port + } + + var added []portEvent + var removed []portEvent + for key, port := range newSet { + if _, ok := oldSet[key]; !ok { + added = append(added, portEvent{Action: "add", Protocol: port.Protocol, Port: port.Port}) + } + } + for key, port := range oldSet { + if _, ok := newSet[key]; !ok { + removed = append(removed, portEvent{Action: "remove", Protocol: port.Protocol, Port: port.Port}) + } + } + sortEvents(added) + sortEvents(removed) + return added, removed +} + +func sortPorts(ports []listenPort) { + sort.Slice(ports, func(i, j int) bool { + if ports[i].Protocol != ports[j].Protocol { + return ports[i].Protocol < ports[j].Protocol + } + return ports[i].Port < ports[j].Port + }) +} + +func sortEvents(events []portEvent) { + sort.Slice(events, func(i, j int) bool { + if events[i].Protocol != events[j].Protocol { + return events[i].Protocol < events[j].Protocol + } + return events[i].Port < events[j].Port + }) +} + +func portKey(port listenPort) string { + return port.Protocol + ":" + strconv.Itoa(int(port.Port)) +} diff --git a/guest/agent/rpc.go b/guest/agent/rpc.go new file mode 100644 index 0000000..2f1c61b --- /dev/null +++ b/guest/agent/rpc.go @@ -0,0 +1,180 @@ +package main + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "io" + "os/exec" + "time" +) + +const maxFrameBytes = 16 * 1024 * 1024 + +type request struct { + ID int `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +type response struct { + ID int `json:"id"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func readFrame(r *bufio.Reader) ([]byte, error) { + var prefix [4]byte + if _, err := io.ReadFull(r, prefix[:]); err != nil { + return nil, err + } + length := binary.BigEndian.Uint32(prefix[:]) + if length > maxFrameBytes { + return nil, errors.New("agent frame too large") + } + payload := make([]byte, length) + _, err := io.ReadFull(r, payload) + return payload, err +} + +func writeFrame(w io.Writer, payload []byte) error { + if len(payload) > maxFrameBytes { + return errors.New("agent frame too large") + } + var prefix [4]byte + binary.BigEndian.PutUint32(prefix[:], uint32(len(payload))) + if _, err := w.Write(prefix[:]); err != nil { + return err + } + _, err := w.Write(payload) + return err +} + +func serveRPC(rw io.ReadWriter) error { + reader := bufio.NewReader(rw) + for { + payload, err := readFrame(reader) + if err != nil { + return err + } + var req request + if err := json.Unmarshal(payload, &req); err != nil { + return writeResponse(rw, response{Error: &rpcError{Code: -32700, Message: err.Error()}}) + } + resp := dispatch(req) + if err := writeResponse(rw, resp); err != nil { + return err + } + } +} + +func writeResponse(w io.Writer, resp response) error { + payload, err := json.Marshal(resp) + if err != nil { + return err + } + return writeFrame(w, payload) +} + +func dispatch(req request) response { + result, err := call(req.Method, req.Params) + if err != nil { + var methodErr methodError + if errors.As(err, &methodErr) { + return response{ID: req.ID, Error: &rpcError{Code: methodErr.code, Message: methodErr.message}} + } + return response{ID: req.ID, Error: &rpcError{Code: -1, Message: err.Error()}} + } + return response{ID: req.ID, Result: result} +} + +type methodError struct { + code int + message string +} + +func (e methodError) Error() string { return e.message } + +func call(method string, params json.RawMessage) (any, error) { + switch method { + case "ping": + return map[string]any{"ok": true, "info": guestInfo()}, nil + case "info": + return guestInfo(), nil + case "exec": + return runExec(params) + case "clock.sync": + var p struct { + HostEpochNS int64 `json:"hostEpochNS"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + return map[string]any{"synced": true}, syncClock(p.HostEpochNS) + case "fsevents.batch": + return applyFSEvents(params) + case "ports.watch": + return currentListeningPorts(params) + case "usb.attach": + return attachUSB(params) + case "usb.detach": + return detachUSB(params) + case "debug.shell": + return debugShell(params) + default: + return nil, methodError{code: -32601, message: "unknown method"} + } +} + +func runExec(params json.RawMessage) (any, error) { + var p struct { + Argv []string `json:"argv"` + Env map[string]string `json:"env"` + StdinB64 string `json:"stdin"` + TimeoutMS int `json:"timeout_ms"` + } + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if len(p.Argv) == 0 { + return nil, errors.New("exec argv is empty") + } + timeout := time.Duration(p.TimeoutMS) * time.Millisecond + if timeout <= 0 { + timeout = 30 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, p.Argv[0], p.Argv[1:]...) + for key, value := range p.Env { + cmd.Env = append(cmd.Env, key+"="+value) + } + if p.StdinB64 != "" { + stdin, err := base64.StdEncoding.DecodeString(p.StdinB64) + if err != nil { + return nil, err + } + cmd.Stdin = bytesReader(stdin) + } + stdout, stderr, code, err := runCommand(cmd) + if ctx.Err() == context.DeadlineExceeded { + return nil, errors.New("exec timed out") + } + if err != nil && code == 0 { + return nil, err + } + return map[string]any{ + "exit_code": code, + "stdout_b64": base64.StdEncoding.EncodeToString(stdout), + "stderr_b64": base64.StdEncoding.EncodeToString(stderr), + }, nil +} diff --git a/guest/agent/rpc_test.go b/guest/agent/rpc_test.go new file mode 100644 index 0000000..e7e6339 --- /dev/null +++ b/guest/agent/rpc_test.go @@ -0,0 +1,239 @@ +package main + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestFrameRoundTrip(t *testing.T) { + var out bytes.Buffer + if err := writeFrame(&out, []byte("hello")); err != nil { + t.Fatal(err) + } + if got := out.Bytes()[:4]; !bytes.Equal(got, []byte{0, 0, 0, 5}) { + t.Fatalf("length prefix = %v", got) + } + payload, err := readFrame(bufio.NewReader(&out)) + if err != nil { + t.Fatal(err) + } + if string(payload) != "hello" { + t.Fatalf("payload = %q", payload) + } +} + +func TestDispatchUnknownMethod(t *testing.T) { + resp := dispatch(request{ID: 7, Method: "nope", Params: json.RawMessage(`{}`)}) + if resp.Error == nil || resp.Error.Code != -32601 { + t.Fatalf("response = %#v", resp) + } +} + +func TestExecMethod(t *testing.T) { + resp := dispatch(request{ + ID: 1, + Method: "exec", + Params: json.RawMessage(`{"argv":["/bin/sh","-c","cat"],"stdin":"` + base64.StdEncoding.EncodeToString([]byte("ok")) + `","timeout_ms":1000}`), + }) + if resp.Error != nil { + t.Fatal(resp.Error) + } + result := resp.Result.(map[string]any) + stdout, err := base64.StdEncoding.DecodeString(result["stdout_b64"].(string)) + if err != nil { + t.Fatal(err) + } + if string(stdout) != "ok" { + t.Fatalf("stdout = %q", stdout) + } +} + +func TestFSEventsBatchReappliesExistingMode(t *testing.T) { + path := filepath.Join(t.TempDir(), "watched.txt") + if err := os.WriteFile(path, []byte("ok"), 0640); err != nil { + t.Fatal(err) + } + resp := dispatch(request{ + ID: 2, + Method: "fsevents.batch", + Params: json.RawMessage(`{"paths":["` + path + `"]}`), + }) + if resp.Error != nil { + t.Fatal(resp.Error) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0640 { + t.Fatalf("mode = %v", info.Mode().Perm()) + } +} + +func TestDebugShellReturnsNsenterArgv(t *testing.T) { + root := t.TempDir() + proc := filepath.Join(root, "proc") + if err := os.MkdirAll(filepath.Join(proc, "42"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(proc, "42", "cgroup"), []byte("0::/docker/abcdef1234567890\n"), 0644); err != nil { + t.Fatal(err) + } + resp := dispatch(request{ + ID: 3, + Method: "debug.shell", + Params: json.RawMessage(`{"containerID":"abcdef1234567890","proc_root":"` + proc + `","toolbox_path":"/.dory-toolbox/bin"}`), + }) + if resp.Error != nil { + t.Fatal(resp.Error) + } + result := resp.Result.(map[string]any) + if result["pid"].(int) != 42 { + t.Fatalf("pid = %#v", result["pid"]) + } + argv := result["argv"].([]string) + want := []string{"nsenter", "--target", "42", "--mount", "--uts", "--ipc", "--net", "--pid", "--", "env", "PATH=/.dory-toolbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "/.dory-toolbox/bin/sh", "-l"} + if !equalStrings(argv, want) { + t.Fatalf("argv = %#v", argv) + } +} + +func TestDebugShellUsesRuntimeStatePID(t *testing.T) { + root := t.TempDir() + state := filepath.Join(root, "run", "containerd", "io.containerd.runtime.v2.task", "moby", "cid123", "init.pid") + if err := os.MkdirAll(filepath.Dir(state), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(state, []byte("77\n"), 0644); err != nil { + t.Fatal(err) + } + pid, err := findContainerPID("cid123", filepath.Join(root, "missing-proc"), filepath.Join(root, "run")) + if err != nil { + t.Fatal(err) + } + if pid != 77 { + t.Fatalf("pid = %d", pid) + } +} + +func TestDebugShellReportsCapabilityErrorWhenContainerMissing(t *testing.T) { + resp := dispatch(request{ + ID: 8, + Method: "debug.shell", + Params: json.RawMessage(`{"containerID":"missing","proc_root":"` + t.TempDir() + `"}`), + }) + if resp.Error == nil || resp.Error.Code != -32002 { + t.Fatalf("response = %#v", resp) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func TestUSBAttachWritesVHCICommand(t *testing.T) { + root := filepath.Join(t.TempDir(), "sys") + vhci := filepath.Join(root, "devices", "platform", "vhci_hcd.0") + if err := os.MkdirAll(vhci, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vhci, "attach"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(vhci, "detach"), nil, 0600); err != nil { + t.Fatal(err) + } + params := json.RawMessage(`{"busid":"3-2.1","port":4,"socket_fd":9,"device_id":196610,"speed":3,"sysfs_root":"` + root + `"}`) + resp := dispatch(request{ID: 5, Method: "usb.attach", Params: params}) + if resp.Error != nil { + t.Fatal(resp.Error) + } + data, err := os.ReadFile(filepath.Join(vhci, "attach")) + if err != nil { + t.Fatal(err) + } + if string(data) != "4 9 196610 3" { + t.Fatalf("attach command = %q", data) + } +} + +func TestUSBDetachWritesVHCIPort(t *testing.T) { + root := filepath.Join(t.TempDir(), "sys") + if err := os.MkdirAll(root, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "attach"), nil, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "detach"), nil, 0600); err != nil { + t.Fatal(err) + } + resp := dispatch(request{ + ID: 6, + Method: "usb.detach", + Params: json.RawMessage(`{"busid":"3-2","port":4,"sysfs_root":"` + root + `"}`), + }) + if resp.Error != nil { + t.Fatal(resp.Error) + } + data, err := os.ReadFile(filepath.Join(root, "detach")) + if err != nil { + t.Fatal(err) + } + if string(data) != "4" { + t.Fatalf("detach command = %q", data) + } +} + +func TestUSBAttachReportsCapabilityErrorWhenVHCIIsMissing(t *testing.T) { + resp := dispatch(request{ + ID: 7, + Method: "usb.attach", + Params: json.RawMessage(`{"busid":"3-2","port":0,"socket_fd":8,"sysfs_root":"` + t.TempDir() + `"}`), + }) + if resp.Error == nil || resp.Error.Code != -32001 { + t.Fatalf("response = %#v", resp) + } +} + +func TestPortsWatchReturnsSnapshot(t *testing.T) { + resp := dispatch(request{ID: 4, Method: "ports.watch", Params: json.RawMessage(`{}`)}) + if resp.Error != nil { + t.Fatal(resp.Error) + } + result := resp.Result.(map[string]any) + if _, ok := result["ports"]; !ok { + t.Fatalf("response missing ports: %#v", result) + } + if _, ok := result["added"]; !ok { + t.Fatalf("response missing added diff: %#v", result) + } + if _, ok := result["removed"]; !ok { + t.Fatalf("response missing removed diff: %#v", result) + } +} + +func TestPortDiffReportsAddsAndRemoves(t *testing.T) { + previous := []listenPort{{Protocol: "tcp", Port: 22}, {Protocol: "tcp", Port: 3000}} + current := []listenPort{{Protocol: "tcp", Port: 3000}, {Protocol: "tcp6", Port: 8080}} + added, removed := diffPorts(previous, current) + if len(added) != 1 || added[0].Action != "add" || added[0].Protocol != "tcp6" || added[0].Port != 8080 { + t.Fatalf("added = %#v", added) + } + if len(removed) != 1 || removed[0].Action != "remove" || removed[0].Protocol != "tcp" || removed[0].Port != 22 { + t.Fatalf("removed = %#v", removed) + } +} diff --git a/guest/agent/usb.go b/guest/agent/usb.go new file mode 100644 index 0000000..10e123b --- /dev/null +++ b/guest/agent/usb.go @@ -0,0 +1,139 @@ +package main + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" +) + +var usbBusIDPattern = regexp.MustCompile(`^[0-9]+-[0-9]+(\.[0-9]+)*$`) + +// buildUsbipImportRequest encodes the 40-byte OP_REQ_IMPORT frame the host's UsbipServer decodes: +// version(0x0111) + code(0x8003) + status(0) big-endian, then a 32-byte NUL-padded/truncated busid. +func buildUsbipImportRequest(busID string) []byte { + req := make([]byte, 40) + binary.BigEndian.PutUint16(req[0:], 0x0111) + binary.BigEndian.PutUint16(req[2:], 0x8003) + binary.BigEndian.PutUint32(req[4:], 0) + copy(req[8:40], []byte(busID)) + return req +} + +func attachUSB(params json.RawMessage) (any, error) { + var p struct { + BusID string `json:"busid"` + Port int `json:"port"` + SocketFD int `json:"socket_fd"` + VsockPort int `json:"vsock_port"` + DeviceID int `json:"device_id"` + Speed int `json:"speed"` + Sysfs string `json:"sysfs_root"` + } + p.Port = -1 + p.SocketFD = -1 + p.VsockPort = -1 + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if !usbBusIDPattern.MatchString(p.BusID) { + return nil, errors.New("invalid usb busid") + } + if p.Port < 0 { + return nil, errors.New("usb attach requires a vhci port") + } + + // Real path: dial the host usbip server ourselves and run the import handshake so vhci gets a + // guest-owned connected fd. A caller-supplied socket_fd (>= 0) stays a test seam that writes the + // vhci command verbatim without touching the network. + sockFD := p.SocketFD + dialed := false + if sockFD < 0 { + if p.VsockPort < 0 { + return nil, methodError{code: -32001, message: "usb attach requires a connected usbip socket fd or a vsock_port to dial"} + } + fd, err := connectVsock(uint32(p.VsockPort)) + if err != nil { + return nil, methodError{code: -32001, message: fmt.Sprintf("usbip vsock dial: %v", err)} + } + if err := usbipImport(fd, p.BusID); err != nil { + _ = closeFD(fd) + return nil, methodError{code: -32001, message: err.Error()} + } + sockFD = fd + dialed = true + } + + root := p.Sysfs + if root == "" { + root = "/sys" + } + vhci, err := findVHCI(root) + if err != nil { + if dialed { + _ = closeFD(sockFD) + } + return nil, methodError{code: -32001, message: err.Error()} + } + command := fmt.Sprintf("%d %d %d %d", p.Port, sockFD, p.DeviceID, p.Speed) + writeErr := os.WriteFile(filepath.Join(vhci, "attach"), []byte(command), 0200) + // vhci dups the fd (sockfd_to_socket/fget), so our copy must be closed after the write to avoid + // leaking it, whether the write succeeded or not. + if dialed { + _ = closeFD(sockFD) + } + if writeErr != nil { + return nil, writeErr + } + return map[string]any{"attached": true, "busid": p.BusID, "port": p.Port}, nil +} + +func detachUSB(params json.RawMessage) (any, error) { + var p struct { + BusID string `json:"busid"` + Port int `json:"port"` + Sysfs string `json:"sysfs_root"` + } + p.Port = -1 + if err := json.Unmarshal(params, &p); err != nil { + return nil, err + } + if p.BusID != "" && !usbBusIDPattern.MatchString(p.BusID) { + return nil, errors.New("invalid usb busid") + } + if p.Port < 0 { + return nil, errors.New("usb detach requires a vhci port") + } + root := p.Sysfs + if root == "" { + root = "/sys" + } + vhci, err := findVHCI(root) + if err != nil { + return nil, methodError{code: -32001, message: err.Error()} + } + if err := os.WriteFile(filepath.Join(vhci, "detach"), []byte(strconv.Itoa(p.Port)), 0200); err != nil { + return nil, err + } + return map[string]any{"detached": true, "busid": p.BusID, "port": p.Port}, nil +} + +func findVHCI(root string) (string, error) { + candidates := []string{ + filepath.Join(root, "devices", "platform", "vhci_hcd.0"), + filepath.Join(root, "platform", "vhci_hcd.0"), + root, + } + for _, candidate := range candidates { + if _, err := os.Stat(filepath.Join(candidate, "attach")); err == nil { + if _, err := os.Stat(filepath.Join(candidate, "detach")); err == nil { + return candidate, nil + } + } + } + return "", errors.New("usbip vhci_hcd sysfs interface is not available") +} diff --git a/guest/agent/usb_test.go b/guest/agent/usb_test.go new file mode 100644 index 0000000..73b5960 --- /dev/null +++ b/guest/agent/usb_test.go @@ -0,0 +1,44 @@ +package main + +import ( + "bytes" + "encoding/binary" + "testing" +) + +func TestBuildUsbipImportRequestMatchesWireFormat(t *testing.T) { + req := buildUsbipImportRequest("3-2") + if len(req) != 40 { + t.Fatalf("want 40-byte OP_REQ_IMPORT, got %d", len(req)) + } + if v := binary.BigEndian.Uint16(req[0:]); v != 0x0111 { + t.Errorf("version = %#04x, want 0x0111", v) + } + if c := binary.BigEndian.Uint16(req[2:]); c != 0x8003 { + t.Errorf("code = %#04x, want 0x8003 (OP_REQ_IMPORT)", c) + } + if s := binary.BigEndian.Uint32(req[4:]); s != 0 { + t.Errorf("status = %d, want 0", s) + } + busid := req[8:40] + if !bytes.HasPrefix(busid, []byte("3-2\x00")) { + t.Errorf("busid not NUL-terminated at start: %q", busid) + } + for _, b := range busid[3:] { + if b != 0 { + t.Errorf("busid tail not NUL-padded: %q", busid) + break + } + } +} + +func TestBuildUsbipImportRequestTruncatesLongBusID(t *testing.T) { + long := "1234567890123456789012345678901234567890" // > 32 chars + req := buildUsbipImportRequest(long) + if len(req) != 40 { + t.Fatalf("want 40 bytes, got %d", len(req)) + } + if !bytes.Equal(req[8:40], []byte(long)[:32]) { + t.Errorf("busid not truncated to 32 bytes") + } +} diff --git a/guest/agent/vsock_linux.go b/guest/agent/vsock_linux.go new file mode 100644 index 0000000..03ded82 --- /dev/null +++ b/guest/agent/vsock_linux.go @@ -0,0 +1,102 @@ +package main + +import ( + "encoding/binary" + "fmt" + "io" + "net" + "os" + + "golang.org/x/sys/unix" +) + +// connectVsock dials the host (VMADDR_CID_HOST) on a vsock port and returns the raw connected fd. +// The fd is guest-owned, which is what vhci_hcd requires — an fd number cannot be passed in over RPC. +func connectVsock(port uint32) (int, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return -1, err + } + if err := unix.Connect(fd, &unix.SockaddrVM{CID: unix.VMADDR_CID_HOST, Port: port}); err != nil { + unix.Close(fd) + return -1, err + } + return fd, nil +} + +func closeFD(fd int) error { return unix.Close(fd) } + +// usbipImport performs the OP_REQ_IMPORT handshake on a connected usbip socket. On success the host's +// OP_REP_IMPORT device descriptor is drained so the kernel stream begins at the first URB. A short +// timeout is applied during the handshake and cleared before the fd is handed to vhci. +func usbipImport(fd int, busID string) error { + deadline := unix.Timeval{Sec: 5} + _ = unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &deadline) + _ = unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_SNDTIMEO, &deadline) + defer func() { + zero := unix.Timeval{} + _ = unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &zero) + _ = unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_SNDTIMEO, &zero) + }() + + if err := writeAllFD(fd, buildUsbipImportRequest(busID)); err != nil { + return fmt.Errorf("usbip import write: %w", err) + } + + head := make([]byte, 8) + if err := readFullFD(fd, head); err != nil { + return fmt.Errorf("usbip import reply: %w", err) + } + if status := binary.BigEndian.Uint32(head[4:]); status != 0 { + return fmt.Errorf("usbip import rejected (status %d)", status) + } + descriptor := make([]byte, 312) // OP_REP_IMPORT device descriptor, discarded + if err := readFullFD(fd, descriptor); err != nil { + return fmt.Errorf("usbip import descriptor: %w", err) + } + return nil +} + +func writeAllFD(fd int, buffer []byte) error { + for len(buffer) > 0 { + n, err := unix.Write(fd, buffer) + if err != nil { + return err + } + buffer = buffer[n:] + } + return nil +} + +func readFullFD(fd int, buffer []byte) error { + for len(buffer) > 0 { + n, err := unix.Read(fd, buffer) + if err != nil { + return err + } + if n == 0 { + return io.ErrUnexpectedEOF + } + buffer = buffer[n:] + } + return nil +} + +func listenVsock(port uint32) (net.Listener, error) { + fd, err := unix.Socket(unix.AF_VSOCK, unix.SOCK_STREAM|unix.SOCK_CLOEXEC, 0) + if err != nil { + return nil, err + } + addr := &unix.SockaddrVM{CID: unix.VMADDR_CID_ANY, Port: port} + if err := unix.Bind(fd, addr); err != nil { + unix.Close(fd) + return nil, err + } + if err := unix.Listen(fd, 128); err != nil { + unix.Close(fd) + return nil, err + } + file := os.NewFile(uintptr(fd), "dory-agent-vsock") + defer file.Close() + return net.FileListener(file) +} diff --git a/guest/agent/vsock_other.go b/guest/agent/vsock_other.go new file mode 100644 index 0000000..f226d0e --- /dev/null +++ b/guest/agent/vsock_other.go @@ -0,0 +1,28 @@ +//go:build !linux + +package main + +import ( + "errors" + "net" +) + +func listenVsock(port uint32) (net.Listener, error) { + _ = port + return net.Listen("tcp", "127.0.0.1:0") +} + +func connectVsock(port uint32) (int, error) { + _ = port + return -1, errors.New("vsock is only supported on the linux guest") +} + +func usbipImport(fd int, busID string) error { + _, _ = fd, busID + return errors.New("usbip is only supported on the linux guest") +} + +func closeFD(fd int) error { + _ = fd + return nil +} diff --git a/guest/kernel/PINS b/guest/kernel/PINS new file mode 100644 index 0000000..3e5c5e4 --- /dev/null +++ b/guest/kernel/PINS @@ -0,0 +1,3 @@ +KERNEL_VERSION=6.12.30 +KERNEL_URL=https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.12.30.tar.xz +KERNEL_SHA256=df046a48971e40ce0b2e003e7e55b6b1e7da2912120eb216d5d6c8450c9cf82e diff --git a/guest/kernel/build.sh b/guest/kernel/build.sh new file mode 100755 index 0000000..3915ac7 --- /dev/null +++ b/guest/kernel/build.sh @@ -0,0 +1,25 @@ +#!/bin/bash +set -euo pipefail +cd "$(dirname "$0")" +source PINS + +OUT="$(pwd)/../out" +mkdir -p "$OUT" + +docker run --rm --platform linux/arm64 \ + -v "$PWD":/src \ + -v "$OUT":/out \ + -w /build \ + debian:12-slim bash -euxc ' + apt-get update + apt-get install -y build-essential flex bison bc libssl-dev libelf-dev xz-utils zstd curl + curl -fsSL '"$KERNEL_URL"' -o linux.tar.xz + echo "'"$KERNEL_SHA256"' linux.tar.xz" | sha256sum -c - + tar xf linux.tar.xz --strip-components=1 + make defconfig + scripts/kconfig/merge_config.sh -m .config /src/dory.config + make olddefconfig + make -j$(nproc) Image + cp arch/arm64/boot/Image /out/Image + zstd -19 -f /out/Image -o /out/Image.zst +' diff --git a/guest/kernel/dory.config b/guest/kernel/dory.config new file mode 100644 index 0000000..24a9e5f --- /dev/null +++ b/guest/kernel/dory.config @@ -0,0 +1,79 @@ +CONFIG_LOCALVERSION="-dory" +CONFIG_MODULES=n +CONFIG_VIRTIO=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_BLK=y +CONFIG_VIRTIO_NET=y +CONFIG_VIRTIO_BALLOON=y +CONFIG_PAGE_REPORTING=y +CONFIG_HW_RANDOM_VIRTIO=y +CONFIG_VSOCKETS=y +CONFIG_VIRTIO_VSOCKETS=y +CONFIG_FUSE_FS=y +CONFIG_VIRTIO_FS=y +CONFIG_DAX=y +CONFIG_FUSE_DAX=y +# FUSE_DAX needs the full FS_DAX -> DAX -> ZONE_DEVICE chain; defconfig leaves ZONE_DEVICE off, +# which silently drops FUSE_DAX under olddefconfig. Pull in the dependencies so the virtio-fs +# guest driver actually compiles DAX support and queries the device's shared-memory window. +CONFIG_MEMORY_HOTPLUG=y +CONFIG_MEMORY_HOTREMOVE=y +CONFIG_SPARSEMEM_VMEMMAP=y +CONFIG_ZONE_DEVICE=y +CONFIG_FS_DAX=y +CONFIG_USBIP_CORE=y +CONFIG_USBIP_VHCI_HCD=y +CONFIG_USB=y +CONFIG_OVERLAY_FS=y +CONFIG_EROFS_FS=y +CONFIG_EXT4_FS=y +CONFIG_BINFMT_MISC=y +CONFIG_CGROUPS=y +CONFIG_NAMESPACES=y +CONFIG_SQUASHFS=y +CONFIG_SQUASHFS_ZSTD=y +CONFIG_CRYPTO_ZSTD=y + +# Headless VM: no physical GPU/audio/media/wireless hardware. Disabling these driver +# subsystems both matches the virtual hardware and avoids fragile defconfig driver builds +# (e.g. drivers/gpu/drm/nouveau) that break the reproducible kernel build. +CONFIG_DRM=n +CONFIG_SOUND=n +CONFIG_MEDIA_SUPPORT=n +CONFIG_WLAN=n +CONFIG_FB=n + +# Console + early boot essentials (headless serial console on the PL011 UART, plus +# device nodes) so the guest is observable and can run a normal userspace. +CONFIG_TTY=y +CONFIG_SERIAL_AMBA_PL011=y +CONFIG_SERIAL_AMBA_PL011_CONSOLE=y +CONFIG_VIRTIO_CONSOLE=y +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y +CONFIG_PRINTK=y +CONFIG_TMPFS=y + +# Container networking/runtime baseline carried forward for Docker and Kubernetes guests. +CONFIG_NETFILTER=y +CONFIG_NF_CONNTRACK=y +CONFIG_NF_NAT=y +CONFIG_NETFILTER_XTABLES=y +CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y +CONFIG_NETFILTER_XT_MATCH_CONNTRACK=y +CONFIG_NETFILTER_XT_MATCH_COMMENT=y +CONFIG_NETFILTER_XT_MATCH_IPVS=y +CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y +CONFIG_IP_NF_IPTABLES=y +CONFIG_IP_NF_FILTER=y +CONFIG_IP_NF_NAT=y +CONFIG_IP_NF_TARGET_MASQUERADE=y +CONFIG_IP6_NF_IPTABLES=y +CONFIG_BRIDGE=y +CONFIG_BRIDGE_NETFILTER=y +CONFIG_VETH=y +CONFIG_TUN=y +CONFIG_IP_VS=y +CONFIG_IP_VS_RR=y +CONFIG_IP_VS_WRR=y +CONFIG_IP_VS_SH=y diff --git a/scripts/benchmark.sh b/scripts/benchmark.sh index d2bfa77..1dae985 100755 --- a/scripts/benchmark.sh +++ b/scripts/benchmark.sh @@ -9,7 +9,9 @@ # (shared VM, socket at ~/.dory/dory.sock) and Apple's `container` CLI for the per-container mode. # This CANNOT run on GitHub-hosted runners — they are VMs without nested virtualization. # -# Usage: scripts/benchmark.sh [count] [image] e.g. scripts/benchmark.sh 2 alpine:latest +# Usage: +# scripts/benchmark.sh [count] [image] # memory benchmark +# scripts/benchmark.sh fileshare [host-dir] # virtio-fs file-sharing benchmark set -euo pipefail COUNT="${1:-2}" @@ -21,6 +23,101 @@ LABEL="dory-bench" [ "$(uname)" = "Darwin" ] || { echo "benchmark requires macOS"; exit 1; } +if [ "${1:-}" = "fileshare" ]; then + shift + SHARE_ROOT="${1:-$PWD/.dory-file-bench}" + RESULT_DIR="${BENCH_RESULT_DIR:-$PWD/docs/research}" + FILE_IMAGE="${DORY_FILE_BENCH_IMAGE:-dory/file-bench:local}" + KERNEL_TREE="${BENCH_KERNEL_TREE:-}" + NPM_PACKAGE="${BENCH_NPM_PACKAGE:-vite@latest}" + mkdir -p "$SHARE_ROOT" "$RESULT_DIR" + + docker_sock_args() { + local sock="$1"; shift + docker -H "unix://$sock" "$@" + } + + ensure_file_bench_image() { + local sock="$1" tmp + if docker_sock_args "$sock" image inspect "$FILE_IMAGE" >/dev/null 2>&1; then return 0; fi + tmp="$(mktemp -d -t dory-file-bench.XXXXXX)" + cat > "$tmp/Dockerfile" <<'DOCKERFILE' +FROM ubuntu:24.04 +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends fio git nodejs npm ca-certificates \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /work +DOCKERFILE + docker_sock_args "$sock" build -t "$FILE_IMAGE" "$tmp" >/dev/null + rm -rf "$tmp" + } + + run_timed() { + /usr/bin/time -p "$@" 2>&1 >/tmp/dory-bench-out.$$ | awk '/^real / { print $2 }' + } + + bench_engine() { + local name="$1" sock="$2" root="$SHARE_ROOT/$name" fio_dir npm_dir git_dir fio_time npm_time git_time + [ -S "$sock" ] || { echo "skip $name: socket not found $sock" >&2; return 0; } + rm -rf "$root" + mkdir -p "$root/fio" "$root/npm" + ensure_file_bench_image "$sock" + + fio_dir="$root/fio" + fio_time="$(run_timed docker_sock_args "$sock" run --rm -v "$fio_dir:/work" "$FILE_IMAGE" \ + fio --name=dory-randread --directory=/work --rw=randread --bs=4k --size=256m --iodepth=16 --numjobs=1 --runtime=30 --time_based --group_reporting)" + + npm_dir="$root/npm" + npm_time="$(run_timed docker_sock_args "$sock" run --rm -v "$npm_dir:/work" "$FILE_IMAGE" \ + sh -lc "npm init -y >/dev/null 2>&1 && npm install $NPM_PACKAGE >/dev/null")" + + git_time="null" + if [ -n "$KERNEL_TREE" ] && [ -d "$KERNEL_TREE/.git" ]; then + git_dir="$root/linux" + rm -rf "$git_dir" + cp -R "$KERNEL_TREE" "$git_dir" + git_time="$(run_timed docker_sock_args "$sock" run --rm -v "$git_dir:/work" "$FILE_IMAGE" git -C /work status --porcelain=v1)" + fi + + cat < Benchmarking file sharing in $SHARE_ROOT" + { + echo "{" + echo " \"shareRoot\": \"$SHARE_ROOT\"," + echo " \"image\": \"$FILE_IMAGE\"," + echo " \"npmPackage\": \"$NPM_PACKAGE\"," + echo " \"kernelTree\": \"${KERNEL_TREE:-}\"," + echo " \"results\": [" + first=1 + for spec in \ + "dory:$DORY_SOCK" \ + "orbstack:${ORBSTACK_DOCKER_SOCK:-$HOME/.orbstack/run/docker.sock}" \ + "docker-desktop:${DOCKER_DESKTOP_SOCK:-$HOME/.docker/run/docker.sock}"; do + name="${spec%%:*}" + sock="${spec#*:}" + result="$(bench_engine "$name" "$sock" || true)" + [ -n "$result" ] || continue + [ "$first" -eq 1 ] || echo "," + first=0 + printf "%s\n" "$result" + done + echo " ]" + echo "}" + } > "$RESULT_DIR/file-sharing-benchmark.json" + echo "wrote $RESULT_DIR/file-sharing-benchmark.json" + exit 0 +fi + # Used memory in bytes = (active + wired + compressed) pages * page size. used_mem() { vm_stat | awk ' diff --git a/scripts/bundle-engine.sh b/scripts/bundle-engine.sh index 8a102a5..05cce6c 100755 --- a/scripts/bundle-engine.sh +++ b/scripts/bundle-engine.sh @@ -1,15 +1,18 @@ #!/bin/bash # Make a built Dory.app self-contained so users download ONLY the app — no `brew install container`. # -# Default ("OrbStack model") injects the in-process engine and pulls the docker engine IMAGE on first -# launch (the image is NOT bundled), the way OrbStack ships an app and fetches engine bits on first -# run. Measured payload ≈ 134MB on disk / ~80MB in the download zip: -# * Contents/Helpers/dory-vm — the signed in-process VM engine helper (~100MB, -# statically links the containerization framework). -# * Contents/Helpers/zstd — decompresses the assets on first launch. +# Default ("OrbStack model") injects the in-process engines and pulls the docker engine IMAGE on +# first launch (the image is NOT bundled), the way OrbStack ships an app and fetches engine bits on +# first run. Bundled payload: +# * Contents/Helpers/dory-hv — Dory's own Hypervisor.framework VMM (elastic memory via free-page +# reporting, SMP, journaled data disk), signed with +# com.apple.security.hypervisor. Preferred when DORY_HV_ENGINE=1. +# * Contents/Helpers/gvproxy — userspace networking (Apache-2.0) for the dory-hv engine. +# * Contents/Helpers/dory-vm — the older Virtualization.framework helper (~100MB), fallback. +# * Contents/Helpers/zstd — decompresses the assets on first launch. # * Contents/Resources/dory-vm-kernel.zst — compressed Linux kernel (~15MB -> ~6MB). # * Contents/Resources/dory-vm-initfs.ext4.zst — compressed VM initfs (~165MB -> ~30MB). -# The docker engine image (docker:dind) is NOT bundled — the helper pulls it on first boot. +# The docker engine image (docker:dind) is NOT bundled — the engine pulls it on first boot. # # Set DORY_BUNDLE_LEGACY=1 to additionally inject the heavy offline payload (the docker:dind image # tarball + Apple's `container` toolchain) for the legacy SharedVMProvisioner path — adds ~600MB. @@ -28,6 +31,172 @@ SUPPORT="$HOME/Library/Application Support/com.apple.container" command -v zstd >/dev/null || { echo "zstd not found (brew install zstd)"; exit 1; } mkdir -p "$RESOURCES" "$HELPERS" +find_debugfs() { + for cand in "$(command -v debugfs 2>/dev/null)" \ + /opt/homebrew/opt/e2fsprogs/sbin/debugfs \ + /usr/local/opt/e2fsprogs/sbin/debugfs; do + [ -n "$cand" ] && [ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +inject_dory_agent_into_initfs() { + local src="$1" agent="$2" out="$3" debugfs_bin init_tmp startup_tmp + INITFS_TO_BUNDLE="$src" + [ "${DORY_SKIP_AGENT_INJECT:-0}" = "1" ] && return 0 + [ -f "$agent" ] || { echo " WARNING: guest agent not found at $agent — run guest/agent/build.sh before bundling for Track 0 RPC"; return 0; } + if ! debugfs_bin="$(find_debugfs)"; then + echo " WARNING: debugfs not found — install e2fsprogs or set DORY_SKIP_AGENT_INJECT=1; bundling initfs without dory-agent" + return 0 + fi + + init_tmp="$(mktemp -t dory-init.XXXXXX)" + startup_tmp="$(mktemp -t dory-agent-init.XXXXXX)" + cp "$src" "$out" + cat > "$startup_tmp" <<'SH' +#!/bin/sh +if [ -x /usr/bin/dory-agent ] && ! pgrep -x dory-agent >/dev/null 2>&1; then + mkdir -p /run + /usr/bin/dory-agent >/run/dory-agent.log 2>&1 & +fi +SH + + "$debugfs_bin" -w -R "mkdir /usr" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "mkdir /usr/bin" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "mkdir /etc" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "rm /usr/bin/dory-agent" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $agent /usr/bin/dory-agent" "$out" >/dev/null + "$debugfs_bin" -w -R "sif /usr/bin/dory-agent mode 0100755" "$out" >/dev/null + "$debugfs_bin" -w -R "rm /etc/dory-agent-init" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $startup_tmp /etc/dory-agent-init" "$out" >/dev/null + "$debugfs_bin" -w -R "sif /etc/dory-agent-init mode 0100755" "$out" >/dev/null + + if "$debugfs_bin" -R "dump /sbin/init $init_tmp" "$out" >/dev/null 2>&1 && ! grep -q "DORY_AGENT_START" "$init_tmp"; then + cat >> "$init_tmp" <<'SH' + +# DORY_AGENT_START +if [ -x /etc/dory-agent-init ]; then + /etc/dory-agent-init || true +fi +# DORY_AGENT_END +SH + "$debugfs_bin" -w -R "rm /sbin/init" "$out" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $init_tmp /sbin/init" "$out" >/dev/null + "$debugfs_bin" -w -R "sif /sbin/init mode 0100755" "$out" >/dev/null + else + echo " WARNING: could not patch /sbin/init; injected /etc/dory-agent-init for initfs builders to source" + fi + + rm -f "$init_tmp" "$startup_tmp" + INITFS_TO_BUNDLE="$out" + echo " injected /usr/bin/dory-agent into initfs" +} + +find_qemu_x86_64_static() { + if [ -n "${DORY_QEMU_X86_64_STATIC:-}" ] && [ -x "$DORY_QEMU_X86_64_STATIC" ]; then + printf '%s\n' "$DORY_QEMU_X86_64_STATIC"; return 0 + fi + for cand in "$(command -v qemu-x86_64-static 2>/dev/null)" \ + /opt/homebrew/bin/qemu-x86_64-static \ + /usr/local/bin/qemu-x86_64-static \ + /opt/homebrew/opt/qemu/bin/qemu-x86_64-static \ + /usr/local/opt/qemu/bin/qemu-x86_64-static; do + [ -n "$cand" ] && [ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; } + done + return 1 +} + +inject_qemu_into_initfs() { + local image="$1" qemu="$2" debugfs_bin + [ "${DORY_SKIP_QEMU_INJECT:-0}" = "1" ] && return 0 + [ -n "$image" ] && [ -f "$image" ] || return 0 + [ -n "$qemu" ] && [ -x "$qemu" ] || return 0 + if ! debugfs_bin="$(find_debugfs)"; then + echo " WARNING: debugfs not found — cannot inject qemu-x86_64-static" + return 0 + fi + "$debugfs_bin" -w -R "mkdir /usr" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "mkdir /usr/bin" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "rm /usr/bin/qemu-x86_64-static" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $qemu /usr/bin/qemu-x86_64-static" "$image" >/dev/null + "$debugfs_bin" -w -R "sif /usr/bin/qemu-x86_64-static mode 0100755" "$image" >/dev/null + echo " injected /usr/bin/qemu-x86_64-static into initfs" +} + +is_linux_aarch64_elf() { + local bin="$1" magic + [ -n "$bin" ] && [ -r "$bin" ] || return 1 + magic="$(dd if="$bin" bs=1 count=4 2>/dev/null | od -An -tx1 | tr -d ' \n')" + [ "$magic" = "7f454c46" ] || return 1 + file "$bin" 2>/dev/null | grep -Eqi 'ELF.*(aarch64|ARM aarch64)' +} + +find_toolbox_binary() { + local name="$1" env_name="DORY_TOOLBOX_$(printf '%s' "$name" | tr '[:lower:]-' '[:upper:]_')" cand + if [ -n "${!env_name:-}" ] && [ -x "${!env_name}" ]; then + if is_linux_aarch64_elf "${!env_name}"; then + printf '%s\n' "${!env_name}"; return 0 + fi + echo " WARNING: $env_name=${!env_name} is not a Linux aarch64 ELF; skipping $name" >&2 + return 1 + fi + for cand in "$(command -v "$name" 2>/dev/null)" \ + "/opt/homebrew/bin/$name" \ + "/usr/local/bin/$name"; do + [ -n "$cand" ] && [ -x "$cand" ] || continue + if is_linux_aarch64_elf "$cand"; then + printf '%s\n' "$cand"; return 0 + fi + done + return 1 +} + +inject_debug_toolbox_into_initfs() { + local image="$1" debugfs_bin busybox curl_bin strace_bin + [ "${DORY_SKIP_TOOLBOX_INJECT:-0}" = "1" ] && return 0 + [ -n "$image" ] && [ -f "$image" ] || return 0 + if ! debugfs_bin="$(find_debugfs)"; then + echo " WARNING: debugfs not found — cannot inject debug toolbox" + return 0 + fi + + busybox="$(find_toolbox_binary busybox || true)" + curl_bin="$(find_toolbox_binary curl || true)" + strace_bin="$(find_toolbox_binary strace || true)" + [ -n "$busybox" ] || echo " WARNING: no Linux aarch64 busybox found; debug toolbox will lack it (set DORY_TOOLBOX_BUSYBOX to a Linux static binary)" + [ -n "$curl_bin" ] || echo " WARNING: no Linux aarch64 curl found; debug toolbox will lack it (set DORY_TOOLBOX_CURL to a Linux static binary)" + [ -n "$strace_bin" ] || echo " WARNING: no Linux aarch64 strace found; debug toolbox will lack it (set DORY_TOOLBOX_STRACE to a Linux static binary)" + if [ -z "$busybox" ] && [ -z "$curl_bin" ] && [ -z "$strace_bin" ]; then + echo " WARNING: no valid Linux toolbox binaries available; skipping debug toolbox injection" + return 0 + fi + + "$debugfs_bin" -w -R "mkdir /.dory-toolbox" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "mkdir /.dory-toolbox/bin" "$image" >/dev/null 2>&1 || true + if [ -n "$busybox" ]; then + "$debugfs_bin" -w -R "rm /.dory-toolbox/bin/busybox" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $busybox /.dory-toolbox/bin/busybox" "$image" >/dev/null + "$debugfs_bin" -w -R "sif /.dory-toolbox/bin/busybox mode 0100755" "$image" >/dev/null + for applet in sh ash cat chmod chown cp env grep ls mkdir mount ps pwd rm sed sleep stat touch umount; do + "$debugfs_bin" -w -R "rm /.dory-toolbox/bin/$applet" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "symlink /.dory-toolbox/bin/$applet busybox" "$image" >/dev/null 2>&1 || true + done + echo " injected debug toolbox busybox ($(basename "$busybox"))" + fi + if [ -n "$curl_bin" ]; then + "$debugfs_bin" -w -R "rm /.dory-toolbox/bin/curl" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $curl_bin /.dory-toolbox/bin/curl" "$image" >/dev/null + "$debugfs_bin" -w -R "sif /.dory-toolbox/bin/curl mode 0100755" "$image" >/dev/null + echo " injected debug toolbox curl" + fi + if [ -n "$strace_bin" ]; then + "$debugfs_bin" -w -R "rm /.dory-toolbox/bin/strace" "$image" >/dev/null 2>&1 || true + "$debugfs_bin" -w -R "write $strace_bin /.dory-toolbox/bin/strace" "$image" >/dev/null + "$debugfs_bin" -w -R "sif /.dory-toolbox/bin/strace mode 0100755" "$image" >/dev/null + echo " injected debug toolbox strace" + fi +} + echo "==> Building + signing the in-process VM engine helper (dory-vm)…" PKG="$(dirname "$0")/../Packages/ContainerizationEngine" if [ -d "$PKG" ]; then @@ -47,6 +216,83 @@ PLIST echo " bundled Helpers/dory-vm (signed with com.apple.security.virtualization)" fi +echo "==> Building + signing the Hypervisor.framework VM engine (dory-hv)…" +# dory-hv is Dory's own VMM: elastic memory via free-page reporting, SMP, journaled data disk. +# It needs only the unrestricted com.apple.security.hypervisor entitlement (no vm.networking). +# The provisioner prefers it when DORY_HV_ENGINE=1 and it is present in Helpers. +if [ -d "$PKG" ]; then + ( cd "$PKG" && swift build -c release --product dory-hv ) + HV_BIN="$(find "$PKG/.build" -name dory-hv -type f -path '*Release*' 2>/dev/null | head -1)" + [ -n "$HV_BIN" ] || HV_BIN="$(find "$PKG/.build" -name dory-hv -type f 2>/dev/null | head -1)" + if [ -n "$HV_BIN" ]; then + cat > /tmp/dory-hv.entitlements <<'PLIST' + + +com.apple.security.hypervisor +PLIST + cp "$HV_BIN" "$HELPERS/dory-hv" + codesign --force --options runtime --timestamp --entitlements /tmp/dory-hv.entitlements \ + -s "${DORY_SIGN_ID:-Developer ID Application}" "$HELPERS/dory-hv" 2>/dev/null \ + || codesign --force --entitlements /tmp/dory-hv.entitlements -s - "$HELPERS/dory-hv" + echo " bundled Helpers/dory-hv (signed with com.apple.security.hypervisor)" + else + echo " WARNING: dory-hv build produced no binary; skipping the HV engine" + fi +fi + +echo "==> Bundling gvproxy (userspace networking for the dory-hv engine)…" +# gvproxy (gvisor-tap-vsock, Apache-2.0) gives the HV engine NAT/DNS with no restricted +# entitlement. Prefer a path from DORY_GVPROXY, else podman's bundled copy, else PATH. +GVPROXY_SRC="${DORY_GVPROXY:-}" +if [ -z "$GVPROXY_SRC" ]; then + for cand in /opt/homebrew/opt/podman/libexec/podman/gvproxy \ + /usr/local/opt/podman/libexec/podman/gvproxy \ + "$(command -v gvproxy 2>/dev/null)"; do + [ -n "$cand" ] && [ -x "$cand" ] && { GVPROXY_SRC="$cand"; break; } + done +fi +if [ -n "$GVPROXY_SRC" ] && [ -x "$GVPROXY_SRC" ]; then + cp "$GVPROXY_SRC" "$HELPERS/gvproxy" + codesign --force --options runtime --timestamp -s "${DORY_SIGN_ID:-Developer ID Application}" "$HELPERS/gvproxy" 2>/dev/null \ + || codesign --force -s - "$HELPERS/gvproxy" + echo " bundled Helpers/gvproxy (from $GVPROXY_SRC)" +else + echo " WARNING: no gvproxy found — the dory-hv engine needs it. Set DORY_GVPROXY or 'brew install podman'." +fi + +echo "==> Bundling the host kubectl + docker CLIs (so k8s and the docker CLI need no separate install)…" +# Host-side CLIs Dory shells out to: kubectl (Kubernetes browser/apply/scale/exec) and docker (the +# optional `docker` context). Bundling them means a fresh download needs nothing installed. Prefer a +# local copy on the build machine, else fetch the darwin/arm64 binary. HostTools resolves the +# bundled copy first at runtime. +ARCH="$(uname -m)"; [ "$ARCH" = "x86_64" ] && KARCH="amd64" || KARCH="arm64" +[ "$ARCH" = "x86_64" ] && DARCH="x86_64" || DARCH="aarch64" + +bundle_cli() { # name local-fallback-path download-url + local name="$1" local_src="$2" url="$3" tmp="/tmp/dory-cli-$1" + if [ -x "$local_src" ]; then cp "$local_src" "$HELPERS/$name" + elif command -v "$name" >/dev/null 2>&1; then cp "$(command -v "$name")" "$HELPERS/$name" + elif [ -n "$url" ]; then curl -fsSL "$url" -o "$tmp" 2>/dev/null && install -m0755 "$tmp" "$HELPERS/$name" && rm -f "$tmp"; fi + if [ -x "$HELPERS/$name" ]; then + codesign --force --options runtime --timestamp -s "${DORY_SIGN_ID:-Developer ID Application}" "$HELPERS/$name" 2>/dev/null \ + || codesign --force -s - "$HELPERS/$name" + echo " bundled Helpers/$name" + else + echo " WARNING: could not bundle $name — the feature will need a system install." + fi +} + +KVER="$(curl -fsSL https://dl.k8s.io/release/stable.txt 2>/dev/null || echo v1.31.0)" +bundle_cli kubectl "" "https://dl.k8s.io/release/${KVER}/bin/darwin/${KARCH}/kubectl" +# The static docker CLI tarball contains a single `docker` binary. +if [ ! -x "$HELPERS/docker" ]; then + DOCKER_TGZ="/tmp/dory-docker.tgz" + if curl -fsSL "https://download.docker.com/mac/static/stable/${DARCH}/docker-27.5.1.tgz" -o "$DOCKER_TGZ" 2>/dev/null; then + tar -xzf "$DOCKER_TGZ" -C /tmp docker/docker 2>/dev/null && install -m0755 /tmp/docker/docker "$HELPERS/docker" && rm -rf "$DOCKER_TGZ" /tmp/docker + fi +fi +bundle_cli docker "" "" + echo "==> Bundling zstd (decompresses the engine assets on first launch)…" ZSTD_BIN="$(command -v zstd)" cp "$ZSTD_BIN" "$HELPERS/zstd" @@ -55,18 +301,40 @@ codesign --force --options runtime -s "${DORY_SIGN_ID:-Developer ID Application} echo " bundled Helpers/zstd" echo "==> Bundling the VM kernel + initfs, compressed (so the engine needs no \`container\` install)…" -KERNEL_SRC="${DORY_KERNEL:-$(ls -t "$SUPPORT"/kernels/vmlinux-* 2>/dev/null | head -1)}" +DORY_GUEST_KERNEL_ZST="$(dirname "$0")/../guest/out/Image.zst" +if [ -n "${DORY_KERNEL:-}" ]; then + KERNEL_SRC="$DORY_KERNEL" +elif [ -f "$DORY_GUEST_KERNEL_ZST" ]; then + KERNEL_SRC="$DORY_GUEST_KERNEL_ZST" +else + KERNEL_SRC="$(ls -t "$SUPPORT"/kernels/vmlinux-* 2>/dev/null | head -1)" +fi INITFS_SRC="${DORY_INITFS:-$(ls -t "$SUPPORT"/containers/*/initfs.ext4 2>/dev/null | head -1)}" +INITFS_TO_BUNDLE="$INITFS_SRC" if [ -n "$KERNEL_SRC" ] && [ -f "$KERNEL_SRC" ]; then - zstd -19 -q -f "$KERNEL_SRC" -o "$RESOURCES/dory-vm-kernel.zst" + if [ "${KERNEL_SRC##*.}" = "zst" ]; then + cp "$KERNEL_SRC" "$RESOURCES/dory-vm-kernel.zst" + else + zstd -19 -q -f "$KERNEL_SRC" -o "$RESOURCES/dory-vm-kernel.zst" + fi echo " bundled Resources/dory-vm-kernel.zst ($(du -h "$RESOURCES/dory-vm-kernel.zst" | awk '{print $1}'), from $(du -h "$KERNEL_SRC" | awk '{print $1}'))" else - echo " WARNING: no kernel found under $SUPPORT/kernels — set DORY_KERNEL to a vmlinux" + echo " WARNING: no kernel found at guest/out/Image.zst or under $SUPPORT/kernels; run guest/kernel/build.sh or set DORY_KERNEL" fi if [ -n "$INITFS_SRC" ] && [ -f "$INITFS_SRC" ]; then + DORY_GUEST_AGENT="$(dirname "$0")/../guest/out/dory-agent" + inject_dory_agent_into_initfs "$INITFS_SRC" "$DORY_GUEST_AGENT" "/tmp/dory-initfs-agent-$$.ext4" + QEMU_X86_64="$(find_qemu_x86_64_static || true)" + if [ -n "$QEMU_X86_64" ]; then + inject_qemu_into_initfs "$INITFS_TO_BUNDLE" "$QEMU_X86_64" + else + echo " WARNING: qemu-x86_64-static not found; amd64 binfmt will rely on the runtime installer fallback" + fi + inject_debug_toolbox_into_initfs "$INITFS_TO_BUNDLE" # --long catches the large zero-fill region in the sparse ext4 (512MB -> ~31MB). - zstd -19 --long=27 -q -f "$INITFS_SRC" -o "$RESOURCES/dory-vm-initfs.ext4.zst" - echo " bundled Resources/dory-vm-initfs.ext4.zst ($(du -h "$RESOURCES/dory-vm-initfs.ext4.zst" | awk '{print $1}'), from $(du -h "$INITFS_SRC" | awk '{print $1}'))" + zstd -19 --long=27 -q -f "$INITFS_TO_BUNDLE" -o "$RESOURCES/dory-vm-initfs.ext4.zst" + echo " bundled Resources/dory-vm-initfs.ext4.zst ($(du -h "$RESOURCES/dory-vm-initfs.ext4.zst" | awk '{print $1}'), from $(du -h "$INITFS_TO_BUNDLE" | awk '{print $1}'))" + [ "$INITFS_TO_BUNDLE" = "$INITFS_SRC" ] || rm -f "$INITFS_TO_BUNDLE" else echo " WARNING: no initfs found — set DORY_INITFS to a built initfs.ext4" fi diff --git a/scripts/ci-test.sh b/scripts/ci-test.sh index 86dd62f..dcdad5b 100755 --- a/scripts/ci-test.sh +++ b/scripts/ci-test.sh @@ -7,6 +7,12 @@ LOG="${DORY_CI_TEST_LOG:-/tmp/dory_ci_tests.log}" ALLOW='^$' +# Retry the whole suite on ANY non-clean attempt, not only when too few tests ran. A shared-runner +# host death is intermittent and can be *partial*: one xctest worker crashes, its tests all report +# "failed" at 0.000s while 300+ others still pass. The old gate treated that first-attempt cascade as +# a hard failure and exited before the retry, turning an infra flake into a red check. Real failures +# reproduce on the retry, so only fail if the suite is still not clean on the second attempt. +last_reason="" for attempt in 1 2; do bash scripts/test.sh -skip-testing:DoryUITests 2>&1 | tee "$LOG" @@ -17,15 +23,17 @@ for attempt in 1 2; do echo "ci-gate: attempt=$attempt passed=$passed" if [ -n "$failed" ]; then printf 'ci-gate known-flaky or failed:\n%s\n' "$failed"; fi - if [ -n "$unexpected" ]; then - printf 'ci-gate: FAIL — unexpected failures:\n%s\n' "$unexpected" - exit 1 - fi - if [ "${passed:-0}" -ge 300 ]; then - echo "ci-gate: OK — suite ran to completion; failures (if any) are known timing flakes" + if [ -z "$unexpected" ] && [ "${passed:-0}" -ge 300 ]; then + echo "ci-gate: OK — suite ran to completion with no unexpected failures" exit 0 fi - echo "ci-gate: only $passed tests ran (shared-runner host death); retrying once" + + if [ -n "$unexpected" ]; then + last_reason="unexpected failures:\n$unexpected" + else + last_reason="only $passed tests ran (shared-runner host death)" + fi + [ "$attempt" -lt 2 ] && echo "ci-gate: attempt $attempt not clean ($last_reason); retrying once" done -echo "ci-gate: FAIL — host died mid-suite on both attempts" +printf 'ci-gate: FAIL — still not clean after retry. %b\n' "$last_reason" exit 1 diff --git a/scripts/dory b/scripts/dory index e809baf..fc82370 100755 --- a/scripts/dory +++ b/scripts/dory @@ -6,6 +6,9 @@ set -euo pipefail DORY_SOCK="${DORY_SOCK:-$HOME/.dory/dory.sock}" KUBECONFIG_DORY="$HOME/.kube/dory-config" CONTAINER_BIN="$(command -v container || echo /opt/homebrew/bin/container)" +RECIPE_DIR="${DORY_RECIPE_DIR:-$HOME/.dory/recipes}" +BUILTIN_RECIPE_DIR="${DORY_BUILTIN_RECIPE_DIR:-$(cd "$(dirname "$0")/.." && pwd)/Dory/Resources/Recipes}" +DOCKER_BIN="${DORY_DOCKER_BIN:-$(command -v docker || echo docker)}" # The framework-engine helper (Rosetta-fast x86 + host mounts via Apple's containerization), bundled # in the app and signed with the virtualization entitlement. Falls back to a local dev build. @@ -17,13 +20,34 @@ vm_helper() { done } +hv_helper() { + for p in "${DORY_HV_BIN:-}" \ + "$(dirname "$0")/../Helpers/dory-hv" \ + "$(dirname "$0")/../Packages/ContainerizationEngine/.build/debug/dory-hv" \ + "$(dirname "$0")/../Packages/ContainerizationEngine/.build/out/Products/Release/dory-hv" \ + "$(dirname "$0")/../Packages/ContainerizationEngine/.build/out/Products/Debug/dory-hv"; do + [ -n "$p" ] && [ -x "$p" ] && { echo "$p"; return; } + done +} + usage() { cat < Run any docker command against Dory's engine dory shell Open an interactive shell in a container + dory debug [-- cmd...] + Enter a container's namespaces through the Dory guest agent dory machine Manage Linux machines (ls, create, start, stop, delete, run) + dory machine snapshot Take a Dory machine snapshot + dory machine restore + Restore, clone, import, export, or delete snapshots + dory machine backup [machine] + Export a snapshot image and upload it with aws s3 cp + dory recipe ls|show|add|new Manage machine recipes + dory usb ls|attach|detach Inspect USB candidates and request USB/IP attach/detach + dory expose [--port P] [--hostname H] [--print-command] + Share a local port or machine endpoint through a public HTTPS tunnel dory k8s Run kubectl against Dory's Kubernetes cluster dory ssh SSH into a Linux machine as you (real ssh when available) dory vm [--image R] [--arch amd64|arm64] [--rosetta] [--mount h:g] -- @@ -35,10 +59,841 @@ dory — Dory CLI EOF } +recipe_files() { + [ -d "$BUILTIN_RECIPE_DIR" ] && find "$BUILTIN_RECIPE_DIR" -maxdepth 1 \( -name '*.yaml' -o -name '*.yml' \) -print + [ -d "$RECIPE_DIR" ] && find "$RECIPE_DIR" -maxdepth 1 \( -name '*.yaml' -o -name '*.yml' \) -print +} + +recipe_field() { + awk -v key="$1" ' + { + key_end = index($0, ":") + if (key_end == 0) next + name = substr($0, 1, key_end - 1) + sub(/^[[:space:]]+/, "", name) + sub(/[[:space:]]+$/, "", name) + if (name != key) next + value = substr($0, key_end + 1) + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + print value + exit + }' "$2" +} + +recipe_path() { + local name="$1" file id + if [ -f "$name" ]; then echo "$name"; return 0; fi + while IFS= read -r file; do + id="$(recipe_field name "$file")" + [ "$id" = "$name" ] && { echo "$file"; return 0; } + done < <(recipe_files | sort) + return 1 +} + +recipe_ls() { + local file id summary distro arch source + printf '%-18s %-14s %-7s %s\n' NAME DISTRO ARCH SUMMARY + while IFS= read -r file; do + id="$(recipe_field name "$file")" + summary="$(recipe_field summary "$file")" + distro="$(recipe_field distro "$file")" + arch="$(recipe_field arch "$file")"; [ -n "$arch" ] || arch="arm64" + case "$file" in "$RECIPE_DIR"/*) source="user" ;; *) source="built-in" ;; esac + [ -n "$id" ] && printf '%-18s %-14s %-7s %s [%s]\n' "$id" "$distro" "$arch" "$summary" "$source" + done < <(recipe_files | sort) +} + +recipe_show() { + local path + path="$(recipe_path "${1:?usage: dory recipe show }")" || { echo "recipe not found: $1" >&2; exit 1; } + cat "$path" +} + +recipe_add() { + local src="${1:?usage: dory recipe add }" tmp name dest + mkdir -p "$RECIPE_DIR" + if [[ "$src" =~ ^https?:// ]]; then + tmp="$(mktemp -t dory-recipe.XXXXXX.yaml)" + curl -fsSL "$src" -o "$tmp" + else + tmp="$src" + fi + [ -f "$tmp" ] || { echo "recipe file not found: $src" >&2; exit 1; } + name="$(recipe_field name "$tmp")" + [ -n "$name" ] || { echo "recipe is missing top-level name:" >&2; exit 1; } + dest="$RECIPE_DIR/$name.yaml" + cp "$tmp" "$dest" + echo "added recipe $name -> $dest" +} + +recipe_new() { + local name="${1:?usage: dory recipe new }" path + mkdir -p "$RECIPE_DIR" + path="$RECIPE_DIR/$name.yaml" + [ ! -e "$path" ] || { echo "recipe already exists: $path" >&2; exit 1; } + cat > "$path" <= 2 and value[0] == value[-1] and value[0] in "'\"": + return value[1:-1] + if value.lower() == "true": + return True + if value.lower() == "false": + return False + try: + return int(value) + except ValueError: + return value + +def parse_inline(value): + value = value.strip() + if value.startswith("[") and value.endswith("]"): + inner = value[1:-1].strip() + if not inner: + return [] + return [scalar(part) for part in inner.split(",")] + if value.startswith("{") and value.endswith("}"): + inner = value[1:-1].strip() + if not inner: + return {} + result = {} + for part in inner.split(","): + key, raw = part.split(":", 1) + result[key.strip()] = scalar(raw) + return result + return scalar(value) + +def load_simple_yaml(path): + root = {} + lines = path and open(path, encoding="utf-8").read().splitlines() or [] + i = 0 + while i < len(lines): + raw = lines[i] + stripped = raw.strip() + if not stripped or stripped.startswith("#") or raw.startswith(" "): + i += 1 + continue + if ":" not in raw: + i += 1 + continue + key, value = raw.split(":", 1) + key = key.strip() + value = value.strip() + if value: + root[key] = parse_inline(value) + i += 1 + continue + items = [] + mapping = {} + i += 1 + while i < len(lines) and (lines[i].startswith(" ") or not lines[i].strip()): + child = lines[i].strip() + if child.startswith("- "): + items.append(scalar(child[2:])) + elif child and ":" in child: + ckey, cvalue = child.split(":", 1) + mapping[ckey.strip()] = parse_inline(cvalue) + i += 1 + root[key] = items if items else mapping + return root + +def sub(value, user): + if isinstance(value, str): + return value.replace("{{host_user}}", host_user).replace("{{user}}", user) + if isinstance(value, list): + return [sub(v, user) for v in value] + if isinstance(value, dict): + return {sub(k, user): sub(v, user) for k, v in value.items()} + return value + +catalog = { + "ubuntu:24.04": ("ubuntu", "Ubuntu", "24.04 LTS", "apt", "systemd"), + "ubuntu:22.04": ("ubuntu", "Ubuntu", "22.04 LTS", "apt", "systemd"), + "ubuntu:20.04": ("ubuntu", "Ubuntu", "20.04 LTS", "apt", "systemd"), + "debian:12": ("debian", "Debian", "12", "apt", "systemd"), + "debian:11": ("debian", "Debian", "11", "apt", "systemd"), + "fedora:41": ("fedora", "Fedora", "41", "dnf", "systemd"), + "fedora:40": ("fedora", "Fedora", "40", "dnf", "systemd"), + "alpine:3.21": ("alpine", "Alpine", "3.21", "apk", "shell"), + "alpine:3.20": ("alpine", "Alpine", "3.20", "apk", "shell"), + "alpine:3.19": ("alpine", "Alpine", "3.19", "apk", "shell"), + "archlinux:latest": ("arch", "Arch Linux", "Rolling", "pacman", "systemd"), +} + +recipe = load_simple_yaml(recipe_path) +recipe_id = str(recipe.get("name") or "").strip() +if not recipe_id: + raise SystemExit("recipe is missing top-level name:") +user_cfg = recipe.get("user") or {} +guest_user_template = str(user_cfg.get("name", "{{host_user}}")) +guest_user = sub(guest_user_template, guest_user_template.replace("{{host_user}}", host_user)) +recipe = sub(recipe, guest_user) +distro = str(recipe.get("distro") or "ubuntu:24.04") +arch = str(recipe.get("arch") or "arm64") +resources = recipe.get("resources") or {} +packages = recipe.get("packages") or [] +runcmd = recipe.get("runcmd") or [] +mounts = recipe.get("mounts") or [] +ports = [int(p) for p in (recipe.get("ports") or [])] +env = recipe.get("env") or {} +docker_enabled = bool(recipe.get("docker", False)) +user_cfg = recipe.get("user") or {} +guest_user = str(user_cfg.get("name") or host_user) +guest_shell = str(user_cfg.get("shell") or "/bin/bash") +if distro not in catalog: + raise SystemExit(f"unsupported recipe distro {distro}") +if arch not in ("arm64", "amd64"): + raise SystemExit(f"unsupported recipe arch {arch}") +family, display, version, pkg, boot = catalog[distro] +base_tag = f"dory-machine/{distro}-{arch}-v2" +recipe_tag = f"dory-recipe/{recipe_id}-{arch}" +container = f"dory-machine-{machine_name}" +platform = f"linux/{arch}" + +def size_mb(value): + text = str(value) + for suffix, mult in (("TiB", 1024 * 1024), ("GiB", 1024), ("MiB", 1)): + if text.endswith(suffix): + return int(text[:-len(suffix)]) * mult + return None + +def quote(value): + return shlex.quote(str(value)) + +def package_install(names): + if not names: + return "" + joined = " ".join(quote(n) for n in names) + if pkg == "apt": + return f"apt-get update -qq && apt-get install -y --no-install-recommends {joined} && rm -rf /var/lib/apt/lists/*" + if pkg == "dnf": + return f"dnf install -y {joined} && dnf clean all" + if pkg == "apk": + return f"apk add --no-cache {joined}" + if pkg == "pacman": + return f"pacman -Sy --noconfirm --needed {joined}" + return "" + +def base_dockerfile(): + if pkg == "apt": + return f"""FROM {distro} +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \\ + && apt-get install -y --no-install-recommends systemd systemd-sysv dbus dbus-user-session sudo bash openssh-server netcat-openbsd ca-certificates iproute2 iputils-ping curl \\ + && rm -rf /var/lib/apt/lists/* \\ + && (systemctl mask systemd-resolved.service systemd-networkd.service || true) +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] +""" + if pkg == "dnf": + return f"""FROM {distro} +RUN dnf -y install systemd sudo passwd iproute procps-ng openssh-server nmap-ncat \\ + && dnf clean all \\ + && (systemctl mask systemd-resolved.service || true) +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] +""" + if pkg == "apk": + return f"""FROM {distro} +RUN apk add --no-cache bash sudo shadow iproute2 ca-certificates openssh netcat-openbsd +CMD ["tail", "-f", "/dev/null"] +""" + if pkg == "pacman": + return f"""FROM {distro} +RUN pacman -Sy --noconfirm --disable-sandbox --needed sudo iproute2 openssh gnu-netcat \\ + && (pacman -Scc --noconfirm --disable-sandbox || true) +STOPSIGNAL SIGRTMIN+3 +CMD ["/sbin/init"] +""" + raise SystemExit(f"unsupported package manager {pkg}") + +provision = [package_install(packages), *[str(c) for c in runcmd]] +if docker_enabled: + provision.append("install -d -m 755 /var/run/dory") +provision_script = "\n".join(line for line in provision if line.strip()) +recipe_dockerfile = f"""FROM {base_tag} +RUN <<'DORY_RECIPE' +set -e +{provision_script} +DORY_RECIPE +""" + +host_config = { + "Privileged": True, + "CgroupnsMode": "host", + "Tmpfs": {"/run": "", "/run/lock": "", "/tmp": ""}, + "RestartPolicy": {"Name": "unless-stopped"}, +} +cpus = int(resources.get("cpus") or 0) +memory = size_mb(resources.get("memory") or "") +if cpus > 0: + host_config["NanoCpus"] = cpus * 1000000000 +if memory: + host_config["Memory"] = memory * 1024 * 1024 +binds = [] +for mount in mounts: + parts = str(mount).split(":") + if len(parts) >= 2: + host = os.path.expanduser(parts[0]) + guest = parts[1].replace("~", f"/home/{guest_user}", 1) if parts[1].startswith("~") else parts[1] + suffix = ":ro" if len(parts) >= 3 and parts[2] == "ro" else "" + binds.append(f"{host}:{guest}{suffix}") +bridge = os.path.join(host_home, ".dory", "bridge", machine_name) +binds.append(f"{bridge}:/opt/dory/bridge") +host_config["Binds"] = binds +if ports: + host_config["PortBindings"] = {f"{p}/tcp": [{"HostPort": str(p)}] for p in ports} + +labels = { + "dory.machine": family, + "dory.machine.version": version, + "dory.machine.arch": arch, + "dory.recipe": recipe_id, +} +if guest_user != "root": + guest_home = host_home if guest_user == host_user else f"/home/{guest_user}" + labels.update({ + "dory.machine.user": guest_user, + "dory.machine.uid": str(host_uid if guest_user == host_user else 1000), + "dory.machine.home": guest_home, + "dory.machine.shell": guest_shell, + }) +if 22 in ports: + labels["dory.machine.sshPort"] = "22" +body = { + "Hostname": machine_name, + "Image": recipe_tag, + "Cmd": ["/sbin/init"] if boot == "systemd" else ["tail", "-f", "/dev/null"], + "Env": sorted([ + "BROWSER=dory-open", + "container=docker", + "SSH_AUTH_SOCK=/opt/dory/bridge/credentials/ssh-agent.sock", + "GIT_ASKPASS=/usr/local/bin/dory-git-askpass", + "DORY_GIT_ASKPASS_SOCK=/opt/dory/bridge/credentials/git-askpass.sock", + *[f"{k}={v}" for k, v in env.items()], + ]), + "StopSignal": "SIGRTMIN+3", + "Labels": labels, + "HostConfig": host_config, +} +if ports: + body["ExposedPorts"] = {f"{p}/tcp": {} for p in ports} + +os.makedirs(out_dir, exist_ok=True) +open(os.path.join(out_dir, "base.Dockerfile"), "w", encoding="utf-8").write(base_dockerfile()) +open(os.path.join(out_dir, "recipe.Dockerfile"), "w", encoding="utf-8").write(recipe_dockerfile) +open(os.path.join(out_dir, "create.json"), "w", encoding="utf-8").write(json.dumps(body, indent=2, sort_keys=True) + "\n") +create_path = "/containers/create?name=" + urllib.parse.quote(container, safe="") + "&platform=" + urllib.parse.quote(platform, safe="") +start_path = "/containers/" + urllib.parse.quote(container, safe="") + "/start" +env_path = os.path.join(out_dir, "plan.env") +with open(env_path, "w", encoding="utf-8") as f: + for key, value in { + "RECIPE_ID": recipe_id, + "DISTRO": distro, + "ARCH": arch, + "PLATFORM": platform, + "BASE_TAG": base_tag, + "RECIPE_TAG": recipe_tag, + "CONTAINER_NAME": container, + "BRIDGE_DIR": bridge, + "CREATE_PATH": create_path, + "START_PATH": start_path, + }.items(): + f.write(f"{key}={quote(value)}\n") +PY +} + +machine_create_recipe() { + local name="" recipe="" dry_run=0 tmp + while [ "$#" -gt 0 ]; do + case "$1" in + --recipe) recipe="${2:?usage: dory machine create --recipe [--dry-run]}"; shift 2 ;; + --dry-run) dry_run=1; shift ;; + --*) echo "unknown machine create option: $1" >&2; exit 2 ;; + *) if [ -z "$name" ]; then name="$1"; shift; else echo "unexpected argument: $1" >&2; exit 2; fi ;; + esac + done + [ -n "$name" ] && [ -n "$recipe" ] || { echo "usage: dory machine create --recipe [--dry-run]" >&2; exit 2; } + [[ "$name" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]*$ ]] || { echo "invalid machine name: $name" >&2; exit 2; } + recipe="$(recipe_path "$recipe")" || { echo "recipe not found: $recipe" >&2; exit 1; } + tmp="$(mktemp -d -t dory-machine-recipe.XXXXXX)" + machine_recipe_plan "$recipe" "$name" "$tmp" + # shellcheck disable=SC1091 + source "$tmp/plan.env" + if [ "$dry_run" -eq 1 ]; then + echo "recipe: $RECIPE_ID" + echo "base image: $BASE_TAG" + echo "recipe image: $RECIPE_TAG" + echo "container: $CONTAINER_NAME" + echo "create path: $CREATE_PATH" + echo "start path: $START_PATH" + echo "--- create.json ---" + sed -n '1,220p' "$tmp/create.json" + echo "--- recipe.Dockerfile ---" + sed -n '1,220p' "$tmp/recipe.Dockerfile" + return 0 + fi + mkdir -p "$BRIDGE_DIR" + if ! "$DOCKER_BIN" -H "unix://$DORY_SOCK" image inspect "$BASE_TAG" >/dev/null 2>&1; then + "$DOCKER_BIN" -H "unix://$DORY_SOCK" build --platform "$PLATFORM" -t "$BASE_TAG" -f "$tmp/base.Dockerfile" "$tmp" + fi + if ! "$DOCKER_BIN" -H "unix://$DORY_SOCK" image inspect "$RECIPE_TAG" >/dev/null 2>&1; then + "$DOCKER_BIN" -H "unix://$DORY_SOCK" build --platform "$PLATFORM" -t "$RECIPE_TAG" -f "$tmp/recipe.Dockerfile" "$tmp" + fi + curl --unix-socket "$DORY_SOCK" -fsS -X POST -H 'Content-Type: application/json' \ + --data-binary @"$tmp/create.json" "http://dory$CREATE_PATH" >/dev/null + curl --unix-socket "$DORY_SOCK" -fsS -X POST "http://dory$START_PATH" >/dev/null + echo "created machine $name from recipe $RECIPE_ID" +} + +dory_urlencode() { + python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" +} + +machine_container_name() { + printf 'dory-machine-%s' "$1" +} + +machine_snapshot_ref() { + local machine="$1" selector="$2" + if [[ "$selector" == sha256:* || "$selector" == *"/"* || "$selector" == *":"* ]]; then + printf '%s' "$selector" + else + printf 'dory-snapshot/%s:%s' "$machine" "$selector" + fi +} + +machine_snapshot_create() { + local name="${1:?usage: dory machine snapshot [--note text] [--tag tag]}" note="" tag="" cid labels body repo path id + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --note) note="${2:?--note requires text}"; shift 2 ;; + --tag) tag="${2:?--tag requires tag}"; shift 2 ;; + --*) echo "unknown snapshot option: $1" >&2; exit 2 ;; + *) note="$1"; shift ;; + esac + done + cid="$(machine_container_name "$name")" + "$DOCKER_BIN" -H "unix://$DORY_SOCK" inspect "$cid" >/dev/null + [ -n "$tag" ] || tag="s$(date -u +%s)" + repo="dory-snapshot/$name" + labels="$("$DOCKER_BIN" -H "unix://$DORY_SOCK" inspect -f '{{json .Config.Labels}}' "$cid")" + body="$(mktemp -t dory-snapshot.XXXXXX.json)" + python3 - "$labels" "$name" "$note" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$body" <<'PY' +import json, sys + +labels = json.loads(sys.argv[1] or "{}") +machine, note, created = sys.argv[2:5] +out = {} +for key in ("dory.machine", "dory.machine.version", "dory.machine.arch", "dory.machine.boot", + "dory.recipe", "dory.machine.user", "dory.machine.uid", "dory.machine.home", + "dory.machine.shell", "dory.machine.sshPort"): + if labels.get(key): + out[key] = labels[key] +out.setdefault("dory.machine.boot", "systemd") +out["dory.snapshot.of"] = machine +out["dory.snapshot.note"] = note +out["dory.snapshot.created"] = created +print(json.dumps({"Labels": out}, separators=(",", ":"))) +PY + path="/commit?container=$(dory_urlencode "$cid")&repo=$(dory_urlencode "$repo")&tag=$(dory_urlencode "$tag")" + id="$(curl --unix-socket "$DORY_SOCK" -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$body" "http://dory$path" \ + | python3 -c 'import json,sys; print(json.load(sys.stdin).get("Id",""))')" + rm -f "$body" + echo "snapshot $repo:$tag ${id:+($id)}" +} + +machine_snapshot_list() { + local machine="${1:-}" filter ids inspect + filter="label=dory.snapshot.of" + [ -z "$machine" ] || filter="label=dory.snapshot.of=$machine" + ids="$("$DOCKER_BIN" -H "unix://$DORY_SOCK" image ls -q --filter "$filter" | sort -u)" + printf '%-32s %-20s %-19s %s\n' IMAGE MACHINE CREATED NOTE + [ -n "$ids" ] || return 0 + inspect="$(mktemp -t dory-snapshots.XXXXXX.json)" + # shellcheck disable=SC2086 + "$DOCKER_BIN" -H "unix://$DORY_SOCK" image inspect $ids > "$inspect" + python3 - "$inspect" <<'PY' +import json, sys + +for image in json.load(open(sys.argv[1])): + labels = image.get("Config", {}).get("Labels") or {} + tags = [t for t in image.get("RepoTags") or [] if t != ":"] + ref = tags[0] if tags else image.get("Id", "") + print(f"{ref[:31]:<32} {labels.get('dory.snapshot.of','')[:19]:<20} {labels.get('dory.snapshot.created','')[:19]:<19} {labels.get('dory.snapshot.note','')}") +PY + rm -f "$inspect" +} + +machine_snapshot_create_body() { + local image="$1" name="$2" out="$3" bridge="$4" inspect + inspect="$(mktemp -t dory-snapshot-image.XXXXXX.json)" + "$DOCKER_BIN" -H "unix://$DORY_SOCK" image inspect "$image" > "$inspect" + python3 - "$inspect" "$image" "$name" "$bridge" > "$out" <<'PY' +import json, sys + +inspect_path, image_ref, name, bridge = sys.argv[1:5] +image = json.load(open(inspect_path))[0] +labels = image.get("Config", {}).get("Labels") or {} +boot = labels.get("dory.machine.boot", "systemd") +arch = labels.get("dory.machine.arch", "arm64") +container_labels = { + "dory.machine": labels.get("dory.machine", labels.get("dory.snapshot.of", name)), + "dory.machine.version": labels.get("dory.machine.version", ""), + "dory.machine.arch": arch, + "dory.machine.boot": boot, +} +for key in ("dory.recipe", "dory.machine.user", "dory.machine.uid", "dory.machine.home", "dory.machine.shell", "dory.machine.sshPort"): + if labels.get(key): + container_labels[key] = labels[key] +cmd = ["/sbin/init"] if boot == "systemd" else ["tail", "-f", "/dev/null"] +body = { + "Hostname": name, + "Image": image_ref, + "Cmd": cmd, + "Env": [ + "BROWSER=dory-open", + "container=docker", + "SSH_AUTH_SOCK=/opt/dory/bridge/credentials/ssh-agent.sock", + "GIT_ASKPASS=/usr/local/bin/dory-git-askpass", + "DORY_GIT_ASKPASS_SOCK=/opt/dory/bridge/credentials/git-askpass.sock", + ], + "StopSignal": "SIGRTMIN+3", + "Labels": container_labels, + "HostConfig": { + "Privileged": True, + "CgroupnsMode": "host", + "Tmpfs": {"/run": "", "/run/lock": "", "/tmp": ""}, + "RestartPolicy": {"Name": "unless-stopped"}, + "Binds": [f"{bridge}:/opt/dory/bridge"], + }, +} +print(json.dumps({"platform": f"linux/{arch}", "body": body}, separators=(",", ":"))) +PY + rm -f "$inspect" +} + +machine_run_snapshot_image() { + local name="$1" image="$2" cid bridge tmp platform create_path + cid="$(machine_container_name "$name")" + bridge="$HOME/.dory/bridge/$name" + mkdir -p "$bridge" + tmp="$(mktemp -t dory-machine-restore.XXXXXX.json)" + machine_snapshot_create_body "$image" "$name" "$tmp" "$bridge" + platform="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["platform"])' "$tmp")" + python3 -c 'import json,sys; print(json.dumps(json.load(open(sys.argv[1]))["body"]))' "$tmp" > "$tmp.body" + "$DOCKER_BIN" -H "unix://$DORY_SOCK" rm -f "$cid" >/dev/null 2>&1 || true + create_path="/containers/create?name=$(dory_urlencode "$cid")&platform=$(dory_urlencode "$platform")" + curl --unix-socket "$DORY_SOCK" -fsS -X POST -H 'Content-Type: application/json' --data-binary @"$tmp.body" "http://dory$create_path" >/dev/null + curl --unix-socket "$DORY_SOCK" -fsS -X POST "http://dory/containers/$(dory_urlencode "$cid")/start" >/dev/null + rm -f "$tmp" "$tmp.body" + echo "started $name from $image" +} + +machine_snapshot_restore() { + local name="${1:?usage: dory machine restore }" selector="${2:?usage: dory machine restore }" image + image="$(machine_snapshot_ref "$name" "$selector")" + machine_run_snapshot_image "$name" "$image" +} + +machine_snapshot_clone() { + local source="${1:?usage: dory machine clone }" selector="${2:?usage: dory machine clone }" name="${3:?usage: dory machine clone }" image + image="$(machine_snapshot_ref "$source" "$selector")" + machine_run_snapshot_image "$name" "$image" +} + +machine_snapshot_export() { + local image dest + if [ "$#" -eq 3 ]; then + image="$(machine_snapshot_ref "$1" "$2")" + dest="$3" + else + image="${1:?usage: dory machine export [machine] }" + dest="${2:?usage: dory machine export [machine] }" + fi + "$DOCKER_BIN" -H "unix://$DORY_SOCK" save -o "$dest" "$image" + echo "$dest" +} + +machine_snapshot_backup() { + local image dest tmp filename + if [ "$#" -eq 3 ]; then + image="$(machine_snapshot_ref "$1" "$2")" + dest="$3" + else + image="${1:?usage: dory machine backup [machine] }" + dest="${2:?usage: dory machine backup [machine] }" + fi + command -v aws >/dev/null 2>&1 || { echo "aws CLI not found; install and configure aws first" >&2; exit 1; } + case "$dest" in s3://*) ;; *) echo "backup destination must be s3://bucket/key-or-prefix" >&2; exit 2 ;; esac + filename="$(printf '%s' "$image" | sed 's#[/:]#-#g' | tr -cd '[:alnum:]_.-').tar" + case "$dest" in + */) dest="$dest$filename" ;; + esac + tmp="$(mktemp -t dory-machine-backup.XXXXXX.tar)" + trap 'rm -f "$tmp"' RETURN + "$DOCKER_BIN" -H "unix://$DORY_SOCK" save -o "$tmp" "$image" + aws s3 cp "$tmp" "$dest" + echo "$dest" +} + +machine_snapshot_import() { + local src="${1:?usage: dory machine import }" + "$DOCKER_BIN" -H "unix://$DORY_SOCK" load -i "$src" +} + +machine_snapshot_delete() { + local image + if [ "$#" -eq 2 ]; then + image="$(machine_snapshot_ref "$1" "$2")" + else + image="${1:?usage: dory machine delete-snapshot [machine] }" + fi + "$DOCKER_BIN" -H "unix://$DORY_SOCK" rmi "$image" +} + +machine_cmd() { + case "${1:-ls}" in + create) + shift + if printf '%s\n' "$*" | grep -q -- '--recipe'; then + machine_create_recipe "$@" + else + exec "$CONTAINER_BIN" machine create "$@" + fi ;; + snapshot) shift; machine_snapshot_create "$@" ;; + snapshots|list-snapshots) shift; machine_snapshot_list "$@" ;; + restore) shift; machine_snapshot_restore "$@" ;; + clone) shift; machine_snapshot_clone "$@" ;; + export) shift; machine_snapshot_export "$@" ;; + backup) shift; machine_snapshot_backup "$@" ;; + import) shift; machine_snapshot_import "$@" ;; + delete-snapshot|rm-snapshot) shift; machine_snapshot_delete "$@" ;; + *) exec "$CONTAINER_BIN" machine "$@" ;; + esac +} + +agent_rpc() { + local sock="$1" method="$2" payload="$3" + [ -n "$sock" ] && [ -S "$sock" ] || { echo "set DORY_AGENT_SOCK to a dory agent bridge socket" >&2; return 2; } + python3 - "$sock" "$method" "$payload" <<'PY' +import json, socket, struct, sys + +def recv_exactly(s, count): + buf = b"" + while len(buf) < count: + chunk = s.recv(count - len(buf)) + if not chunk: + return buf + buf += chunk + return buf + +sock_path, method, payload = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) +request = json.dumps({"id": 1, "method": method, "params": payload}, separators=(",", ":")).encode() +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(sock_path) + s.sendall(struct.pack(">I", len(request)) + request) + header = recv_exactly(s, 4) + if len(header) != 4: + raise SystemExit("agent closed before response") + length = struct.unpack(">I", header)[0] + data = recv_exactly(s, length) + if len(data) != length: + raise SystemExit("agent closed mid-response") +response = json.loads(data) +if response.get("error"): + print(response["error"].get("message", "agent request failed"), file=sys.stderr) + raise SystemExit(1) +print(json.dumps(response.get("result", {}), sort_keys=True)) +PY +} + +usb_rpc() { + local method="$1" payload="$2" + local sock="${DORY_USB_AGENT_SOCK:-${DORY_AGENT_SOCK:-}}" + agent_rpc "$sock" "$method" "$payload" +} + +debug_payload() { + local container="$1" + shift + python3 - "$container" "$@" <<'PY' +import json, sys + +container = sys.argv[1] +argv = sys.argv[2:] +payload = {"containerID": container} +if argv: + payload["argv"] = argv +print(json.dumps(payload, separators=(",", ":"))) +PY +} + +debug_cmd() { + local container="${1:?usage: dory debug [-- cmd...]}" + shift + [ "${1:-}" != "--" ] || shift + local sock="${DORY_DEBUG_AGENT_SOCK:-${DORY_AGENT_SOCK:-}}" + local result + result="$(agent_rpc "$sock" "debug.shell" "$(debug_payload "$container" "$@")")" + if [ "$#" -eq 0 ]; then + printf '%s\n' "$result" + else + python3 - "$result" <<'PY' +import base64, json, sys + +result = json.loads(sys.argv[1]) +if result.get("stdout_b64"): + sys.stdout.buffer.write(base64.b64decode(result["stdout_b64"])) +if result.get("stderr_b64"): + sys.stderr.buffer.write(base64.b64decode(result["stderr_b64"])) +raise SystemExit(int(result.get("exit_code", 0))) +PY + fi +} + +usb_cmd() { + case "${1:-ls}" in + ls) + local hv + hv="$(hv_helper || true)" + if [ -n "$hv" ]; then + "$hv" usb list + else + system_profiler SPUSBDataType 2>/dev/null || ioreg -p IOUSB -l + fi + ;; + attach) + shift + local busid="" mode="" + while [ "$#" -gt 0 ]; do + case "$1" in + --mode) mode="${2:?usage: dory usb attach [--mode userAuthorized|seize|capture]}"; shift 2 ;; + --*) echo "unknown usb attach option: $1" >&2; exit 2 ;; + *) if [ -z "$busid" ]; then busid="$1"; fi; shift ;; + esac + done + [ -n "$busid" ] || { echo "usage: dory usb attach [--mode userAuthorized|seize|capture]" >&2; exit 2; } + local hv; hv="$(hv_helper || true)" + [ -n "$hv" ] || { echo "dory usb attach needs the dory-hv engine running" >&2; exit 2; } + # The engine (which owns the device claim + guest agent) does the claim, registers the usbip + # export, and tells the guest to dial. Mode defaults to userAuthorized (driverless devices); + # capture (driver-owned devices, e.g. mass storage) needs the notarized com.apple.vm.device-access. + if [ -n "$mode" ]; then "$hv" usb attach "$busid" "$mode"; else "$hv" usb attach "$busid"; fi + ;; + detach) + shift + local busid="" + while [ "$#" -gt 0 ]; do + case "$1" in + --*) echo "unknown usb detach option: $1" >&2; exit 2 ;; + *) if [ -z "$busid" ]; then busid="$1"; fi; shift ;; + esac + done + [ -n "$busid" ] || { echo "usage: dory usb detach " >&2; exit 2; } + local hv; hv="$(hv_helper || true)" + [ -n "$hv" ] || { echo "dory usb detach needs the dory-hv engine running" >&2; exit 2; } + "$hv" usb detach "$busid" + ;; + *) echo "usage: dory usb ls|attach|detach" >&2; exit 2 ;; + esac +} + +expose_cmd() { + local target="" port="" hostname="" scheme="http" print_command=0 cloudflared url + while [ "$#" -gt 0 ]; do + case "$1" in + --port) port="${2:?usage: dory expose [--port P] [--hostname H] [--print-command]}"; shift 2 ;; + --hostname) hostname="${2:?usage: dory expose [--port P] [--hostname H] [--print-command]}"; shift 2 ;; + --scheme) scheme="${2:?usage: dory expose [--scheme http|https]}"; shift 2 ;; + --print-command) print_command=1; shift ;; + --*) echo "unknown expose option: $1" >&2; exit 2 ;; + *) if [ -z "$target" ]; then target="$1"; shift; else echo "unexpected argument: $1" >&2; exit 2; fi ;; + esac + done + [ -n "$target" ] || { echo "usage: dory expose [--port P] [--hostname H] [--print-command]" >&2; exit 2; } + [[ "$scheme" == "http" || "$scheme" == "https" ]] || { echo "invalid expose scheme: $scheme" >&2; exit 2; } + if [[ "$target" =~ ^[0-9]+$ ]]; then + port="$target" + [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "invalid expose port: $port" >&2; exit 2; } + url="$scheme://127.0.0.1:$port" + else + [[ "$target" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$ ]] || { echo "invalid machine name: $target" >&2; exit 2; } + port="${port:-80}" + [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] || { echo "invalid expose port: $port" >&2; exit 2; } + url="$scheme://$target.dory.local:$port" + fi + if [ -n "$hostname" ]; then + [[ "$hostname" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$ ]] || { + echo "invalid expose hostname: $hostname" >&2; exit 2 + } + fi + cloudflared="${DORY_CLOUDFLARED_BIN:-$(command -v cloudflared || true)}" + [ -n "$cloudflared" ] || { echo "cloudflared not found; install it or set DORY_CLOUDFLARED_BIN" >&2; exit 1; } + if [ -n "$hostname" ]; then + local tunnel="${DORY_CLOUDFLARED_TUNNEL:-}" + [ -n "$tunnel" ] || { + echo "exposing on a named hostname requires a pre-created cloudflared tunnel with ingress mapping $hostname -> $url" >&2 + echo "create it with: cloudflared tunnel create && cloudflared tunnel route dns $hostname" >&2 + echo "then set DORY_CLOUDFLARED_TUNNEL= and re-run" >&2 + exit 2 + } + local cmd=("$cloudflared" tunnel run "$tunnel") + else + local cmd=("$cloudflared" tunnel --url "$url") + fi + if [ "$print_command" -eq 1 ]; then + printf '%q ' "${cmd[@]}" + printf '\n' + else + exec "${cmd[@]}" + fi +} + case "${1:-help}" in help|-h|--help) usage ;; shell) exec docker -H "unix://$DORY_SOCK" exec -it "$2" sh -c 'command -v bash >/dev/null && exec bash || exec sh' ;; - machine) shift; exec "$CONTAINER_BIN" machine "$@" ;; + debug) shift; debug_cmd "$@" ;; + machine) shift; machine_cmd "$@" ;; + recipe) shift + case "${1:-ls}" in + ls) recipe_ls ;; + show) shift; recipe_show "$@" ;; + add) shift; recipe_add "$@" ;; + new) shift; recipe_new "$@" ;; + *) echo "usage: dory recipe ls|show|add|new" >&2; exit 2 ;; + esac ;; + usb) shift; usb_cmd "$@" ;; + expose) shift; expose_cmd "$@" ;; ssh) shift name="${1:?usage: dory ssh }" cid="dory-machine-$name" diff --git a/scripts/enable-networking.sh b/scripts/enable-networking.sh index ac8c235..3623a21 100755 --- a/scripts/enable-networking.sh +++ b/scripts/enable-networking.sh @@ -7,11 +7,13 @@ # host-wide with no per-app config. DNS on a high port needs no root binding. # 2) pf redirect :80→:8080 and :443→:8443, so http(s)://.dory.local works with no port. # 3) Installs Dory's local CA into the System trust store so the HTTPS is trusted. +# 4) Optional: --direct-ip adds the container subnet route used by the direct-IP data path. # # Undo: # sudo rm /etc/resolver/dory.local # sudo pfctl -a com.dory.rdr -F nat 2>/dev/null; sudo pfctl -d 2>/dev/null # sudo security delete-certificate -c "Dory Local CA" /Library/Keychains/System.keychain +# sudo /sbin/route -n delete -net 192.168.215.0/24 set -euo pipefail SUFFIX="dory.local" @@ -19,6 +21,88 @@ DNS_PORT=15353 HTTP_PORT=8080 HTTPS_PORT=8443 CA_CERT="$HOME/.dory/ca/ca.crt" +DIRECT_IP=0 +REMOVE=0 +CONTAINER_SUBNET="192.168.215.0/24" +HOST_GATEWAY="192.168.127.1" +GUEST_GATEWAY="192.168.127.2" +DIRECT_IP_INTERFACE="" +DIRECT_IP_INTERFACE_FILE="$HOME/.dory/hv/direct-ip.interface" + +usage() { + cat <&2; usage >&2; exit 2 ;; + esac +done + +valid_cidr "$CONTAINER_SUBNET" || { echo "invalid --container-subnet: $CONTAINER_SUBNET" >&2; exit 2; } +valid_ipv4 "$HOST_GATEWAY" || { echo "invalid --host-gateway: $HOST_GATEWAY" >&2; exit 2; } +valid_ipv4 "$GUEST_GATEWAY" || { echo "invalid --guest-gateway: $GUEST_GATEWAY" >&2; exit 2; } + +if [ "$REMOVE" = "1" ]; then + echo "==> Removing Dory networking integration…" + sudo rm -f "/etc/resolver/$SUFFIX" + sudo pfctl -a com.dory.rdr -F nat 2>/dev/null || true + if [ "$DIRECT_IP" = "1" ]; then + if [ -z "$DIRECT_IP_INTERFACE" ] && [ -f "$DIRECT_IP_INTERFACE_FILE" ]; then + DIRECT_IP_INTERFACE="$(tr -d '[:space:]' < "$DIRECT_IP_INTERFACE_FILE")" + fi + sudo /sbin/route -n delete -net "$CONTAINER_SUBNET" 2>/dev/null || true + if valid_interface "$DIRECT_IP_INTERFACE"; then + sudo /sbin/ifconfig "$DIRECT_IP_INTERFACE" down 2>/dev/null || true + fi + fi + echo "Done." + exit 0 +fi echo "==> Pointing the system resolver for *.$SUFFIX at Dory's DNS (127.0.0.1:$DNS_PORT)…" sudo mkdir -p /etc/resolver @@ -39,6 +123,21 @@ if ! grep -q "$ANCHOR" /etc/pf.conf 2>/dev/null; then fi sudo pfctl -f /etc/pf.conf -e 2>/dev/null || true +if [ "$DIRECT_IP" = "1" ]; then + if [ -z "$DIRECT_IP_INTERFACE" ] && [ -f "$DIRECT_IP_INTERFACE_FILE" ]; then + DIRECT_IP_INTERFACE="$(tr -d '[:space:]' < "$DIRECT_IP_INTERFACE_FILE")" + fi + valid_interface "$DIRECT_IP_INTERFACE" || { + echo "direct IP needs a running Dory engine utun interface; start Dory, then re-run or pass --direct-ip-interface utunN" >&2 + exit 2 + } + echo "==> Configuring $DIRECT_IP_INTERFACE as $HOST_GATEWAY → $GUEST_GATEWAY for direct container/machine IP access…" + sudo /sbin/ifconfig "$DIRECT_IP_INTERFACE" inet "$HOST_GATEWAY" "$GUEST_GATEWAY" up + echo "==> Routing $CONTAINER_SUBNET through $DIRECT_IP_INTERFACE…" + sudo /sbin/route -n delete -net "$CONTAINER_SUBNET" 2>/dev/null || true + sudo /sbin/route -n add -net "$CONTAINER_SUBNET" -interface "$DIRECT_IP_INTERFACE" +fi + echo "==> Installing Dory's local CA into the System trust store…" if [ ! -f "$CA_CERT" ]; then echo " CA not found at $CA_CERT — open Dory once (it generates the CA), then re-run." diff --git a/scripts/readiness.sh b/scripts/readiness.sh index 7694693..79f7fdf 100755 --- a/scripts/readiness.sh +++ b/scripts/readiness.sh @@ -12,7 +12,15 @@ # Environment knobs: # DORY_SOCK, ORBSTACK_SOCK, DOCKER_DESKTOP_SOCK # READINESS_WORKDIR, READINESS_SETTLE, READINESS_ALPINE_IMAGE, READINESS_NGINX_IMAGE -# RUN_MEMORY=0|1, RUN_AMD64=0|1, RUN_ONLINE=0|1, RUN_DOMAINS=0|1, RUN_K8S=0|1, RUN_MACHINES=0|1 +# RUN_MEMORY=0|1, RUN_AMD64=0|1, RUN_ONLINE=0|1, RUN_DOMAINS=0|1, RUN_DIRECT_IP=0|1, RUN_FILE_WATCH=0|1, RUN_K8S=0|1, RUN_MACHINES=0|1, RUN_MACHINE_RECIPE=0|1, RUN_USB=0|1, RUN_VPN=0|1 +# DORY_DIRECT_IP_INTERFACE_FILE points at the helper-written utun interface file +# READINESS_FILE_WATCH_IMAGE for --file-watch (default: alpine image) +# READINESS_MACHINE_RECIPE, READINESS_MACHINE_RECIPE_COMMAND for --machine-recipe +# RUN_DEBUG_SHELL=0|1, DORY_DEBUG_AGENT_SOCK, DORY_DEBUG_CONTAINER_ID +# RUN_CLOCK_SYNC=0|1, DORY_CLOCK_SYNC_AGENT_SOCK, DORY_CLOCK_SYNC_PID, DORY_CLOCK_SYNC_TOLERANCE_MS +# RUN_GUEST_AGENT=0|1, DORY_HV_BIN, DORY_GUEST_KERNEL, DORY_GUEST_INITFS +# DORY_USB_TEST_BUSID, DORY_USB_AGENT_SOCK, DORY_USBIP_SOCKET_FD for --usb hardware smoke +# DORY_REQUIRE_VPN=1 makes --vpn fail when no active VPN-like interface or route is detected # STOP_ORBSTACK=1 to quit OrbStack before running Dory-only checks set -u @@ -26,9 +34,20 @@ RUN_MEMORY="${RUN_MEMORY:-1}" RUN_AMD64="${RUN_AMD64:-1}" RUN_ONLINE="${RUN_ONLINE:-0}" RUN_DOMAINS="${RUN_DOMAINS:-0}" +RUN_DIRECT_IP="${RUN_DIRECT_IP:-0}" +RUN_FILE_WATCH="${RUN_FILE_WATCH:-0}" RUN_K8S="${RUN_K8S:-0}" RUN_MACHINES="${RUN_MACHINES:-0}" +RUN_MACHINE_RECIPE="${RUN_MACHINE_RECIPE:-0}" RUN_BRIDGE="${RUN_BRIDGE:-0}" +RUN_GUEST_AGENT="${RUN_GUEST_AGENT:-0}" +RUN_DAX="${RUN_DAX:-0}" +RUN_ROSETTA="${RUN_ROSETTA:-0}" +RUN_USB="${RUN_USB:-0}" +RUN_VPN="${RUN_VPN:-0}" +RUN_DEBUG_SHELL="${RUN_DEBUG_SHELL:-0}" +RUN_CLOCK_SYNC="${RUN_CLOCK_SYNC:-0}" +CLOCK_SYNC_TOLERANCE_MS="${DORY_CLOCK_SYNC_TOLERANCE_MS:-100}" STOP_ORBSTACK="${STOP_ORBSTACK:-0}" RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-$$" @@ -60,11 +79,25 @@ Options: --skip-amd64 Skip linux/amd64 emulation check --online Run online registry search check --domains Run *.dory.local / *.orb.local checks when integration is active + --direct-ip Run Dory direct container-IP ping + browser check (requires system integration route) + --file-watch Run host edit to inotify propagation check for bind/shared mounts --k8s Run Kubernetes context checks --machines Run Linux machine CLI checks + --machine-recipe Create a Linux machine from a recipe and assert its provisioned command --bridge Run guest→host bridge (dory-open) check + --guest-agent Run dory-hv guest-agent vsock smoke (requires DORY_GUEST_KERNEL and DORY_GUEST_INITFS) + --dax Run dory-hv virtio-fs DAX coherence probe (requires a signed dory-hv, DORY_HV_BIN) + --rosetta Run Rosetta x86-64 machine execution smoke (requires a signed dory-vm + Rosetta; DORY_VM_HELPER) + --usb Run USB/IP hardware smoke when DORY_USB_TEST_BUSID and agent socket settings are set + --vpn Record route/DNS state and run userspace networking checks during VPN coexistence testing + --debug-shell Run debug shell smoke when DORY_DEBUG_AGENT_SOCK and DORY_DEBUG_CONTAINER_ID are set + --clock-sync Run host-wake clock sync smoke when agent socket and helper PID are available --stop-orbstack Quit OrbStack before Dory-only runs -h, --help Show this help + +Clock sync env: + DORY_CLOCK_SYNC_AGENT_SOCK or DORY_AGENT_SOCK + DORY_CLOCK_SYNC_PID, DORY_CLOCK_SYNC_TOLERANCE_MS EOF } @@ -77,9 +110,19 @@ while [ "$#" -gt 0 ]; do --skip-amd64) RUN_AMD64=0; shift ;; --online) RUN_ONLINE=1; shift ;; --domains) RUN_DOMAINS=1; shift ;; + --direct-ip) RUN_DIRECT_IP=1; shift ;; + --file-watch) RUN_FILE_WATCH=1; shift ;; --k8s) RUN_K8S=1; shift ;; --machines) RUN_MACHINES=1; shift ;; + --machine-recipe) RUN_MACHINE_RECIPE=1; shift ;; --bridge) RUN_BRIDGE=1; shift ;; + --guest-agent) RUN_GUEST_AGENT=1; shift ;; + --dax) RUN_DAX=1; shift ;; + --rosetta) RUN_ROSETTA=1; shift ;; + --usb) RUN_USB=1; shift ;; + --vpn) RUN_VPN=1; shift ;; + --debug-shell) RUN_DEBUG_SHELL=1; shift ;; + --clock-sync) RUN_CLOCK_SYNC=1; shift ;; --stop-orbstack) STOP_ORBSTACK=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "unknown option: $1" >&2; usage; exit 2 ;; @@ -141,7 +184,7 @@ used_mem() { process_rss_bytes() { local engine="$1" pattern case "$engine" in - dory) pattern="${DORY_PROCESS_PATTERN:-Dory|dory-vm|containermanagerd}" ;; + dory) pattern="${DORY_PROCESS_PATTERN:-Dory|dory-vm|dory-vmboot|containermanagerd}" ;; orbstack) pattern="${ORBSTACK_PROCESS_PATTERN:-OrbStack}" ;; docker-desktop|desktop) pattern="${DOCKER_DESKTOP_PROCESS_PATTERN:-Docker|com.docker}" ;; *) pattern="${GENERIC_ENGINE_PROCESS_PATTERN:-$engine}" ;; @@ -253,6 +296,37 @@ test_bind_mount() { grep -q 'from-container' "$dir/output.txt" } +test_file_watch() { + local dir="$WORKDIR/${ENGINE_ID}-file-watch" + local name="$PREFIX-file-watch" + local image="${READINESS_FILE_WATCH_IMAGE:-$ALPINE_IMAGE}" + mkdir -p "$dir" + printf 'before\n' > "$dir/input.txt" + docker_e rm -f "$name" >/dev/null 2>&1 || true + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" -v "$dir:/work" "$image" sh -lc ' + apk add --no-cache inotify-tools >/dev/null + touch /work/ready + timeout 20 inotifywait -q -e modify,attrib,create,delete /work/input.txt > /work/event.txt + ' >/dev/null + trap 'docker_e rm -f "$name" >/dev/null 2>&1 || true' RETURN + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + [ -f "$dir/ready" ] && break + docker_e inspect "$name" --format '{{.State.Running}}' 2>/dev/null | grep -q true || { docker_e logs "$name" 2>&1; return 1; } + sleep 1 + done + [ -f "$dir/ready" ] || { docker_e logs "$name" 2>&1; echo "inotify watcher did not become ready"; return 1; } + sleep 1 + printf 'after\n' >> "$dir/input.txt" + for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -s "$dir/event.txt" ] && break + sleep 1 + done + [ -s "$dir/event.txt" ] || { docker_e logs "$name" 2>&1; echo "host edit did not produce an inotify event"; return 1; } + grep -Eq 'MODIFY|ATTRIB|CREATE|DELETE' "$dir/event.txt" + docker_e rm -f "$name" >/dev/null 2>&1 || true + trap - RETURN +} + test_volume_roundtrip() { local vol="$PREFIX-vol" docker_e volume create --label "$LABEL_KEY=$RUN_ID" "$vol" >/dev/null @@ -372,6 +446,11 @@ test_resource_limits_update() { } test_amd64() { + local name="$PREFIX-binfmt" + docker_e run --rm --privileged --name "$name" --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" \ + sh -c 'test -e /proc/sys/fs/binfmt_misc/register || { echo "binfmt_misc not mounted" >&2; exit 1; } + test -e /proc/sys/fs/binfmt_misc/qemu-x86_64 || { echo "qemu-x86_64 handler not registered" >&2; exit 1; } + grep -qx enabled /proc/sys/fs/binfmt_misc/qemu-x86_64 || { echo "qemu-x86_64 handler not enabled" >&2; exit 1; }' docker_e run --rm --platform linux/amd64 --label "$LABEL_KEY=$RUN_ID" "$ALPINE_IMAGE" uname -m | grep -Eq 'x86_64|amd64' } @@ -396,6 +475,84 @@ test_domains() { return 1 } +test_direct_ip() { + [ "$CURRENT_ENGINE" = "dory" ] || { echo "direct container IP routing is a Dory shared-VM claim"; return 1; } + local name="$PREFIX-direct-ip" ip iface_file iface route_info + iface_file="${DORY_DIRECT_IP_INTERFACE_FILE:-$HOME/.dory/hv/direct-ip.interface}" + [ -f "$iface_file" ] || { + echo "direct-IP interface file missing: $iface_file; start Dory and run scripts/enable-networking.sh --direct-ip" + return 1 + } + iface="$(tr -d '[:space:]' < "$iface_file")" + [ -n "$iface" ] || { echo "direct-IP interface file is empty: $iface_file"; return 1; } + if ! ifconfig "$iface" >/dev/null 2>&1; then + echo "direct-IP interface is not active: $iface" + return 1 + fi + docker_e run -d --name "$name" --label "$LABEL_KEY=$RUN_ID" "$NGINX_IMAGE" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10; do + ip="$(docker_e inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name" 2>/dev/null)" + [ -n "$ip" ] && break + sleep 1 + done + [ -n "$ip" ] || { docker_e rm -f "$name" >/dev/null; echo "container has no IPv4 address"; return 1; } + route_info="$(route -n get "$ip" 2>/dev/null || true)" + if ! printf '%s\n' "$route_info" | grep -Eq "interface: +$iface"; then + docker_e rm -f "$name" >/dev/null + echo "host route for $ip does not use $iface; run scripts/enable-networking.sh --direct-ip" + return 1 + fi + ping -c 1 -W 1000 "$ip" >/dev/null + curl -fsS --connect-timeout 3 "http://$ip" | grep -qi 'welcome' + docker_e rm -f "$name" >/dev/null +} + +vpn_snapshot() { + local dir="$1" + mkdir -p "$dir" + if command -v ifconfig >/dev/null 2>&1; then + ifconfig > "$dir/interfaces.txt" 2>&1 || true + elif command -v ip >/dev/null 2>&1; then + ip addr show > "$dir/interfaces.txt" 2>&1 || true + fi + if command -v netstat >/dev/null 2>&1; then + netstat -rn > "$dir/routes.txt" 2>&1 || true + elif command -v ip >/dev/null 2>&1; then + ip route show table all > "$dir/routes.txt" 2>&1 || true + fi + if command -v route >/dev/null 2>&1; then + route -n get default > "$dir/default-route.txt" 2>&1 || true + fi + if command -v scutil >/dev/null 2>&1; then + scutil --dns > "$dir/dns.txt" 2>&1 || true + elif [ -f /etc/resolv.conf ]; then + cp /etc/resolv.conf "$dir/dns.txt" 2>/dev/null || true + fi +} + +vpn_detected() { + local dir="$1" + grep -Eiq '(^utun[0-9]*:|^ppp[0-9]*:|^tun[0-9]*:|^tap[0-9]*:|wireguard|wg[0-9]|tailscale|zerotier|vpn)' \ + "$dir/interfaces.txt" "$dir/routes.txt" "$dir/dns.txt" 2>/dev/null +} + +test_vpn_coexistence() { + [ "$CURRENT_ENGINE" = "dory" ] || { echo "VPN coexistence is a Dory gvproxy claim"; return 1; } + local dir="$WORKDIR/${ENGINE_ID}-vpn" + vpn_snapshot "$dir" + if vpn_detected "$dir"; then + echo "VPN-like interface or route detected; artifacts: $dir" + elif [ "${DORY_REQUIRE_VPN:-0}" = "1" ]; then + echo "no VPN-like interface or route detected; artifacts: $dir" + return 1 + else + echo "no VPN-like interface or route detected; recorded baseline artifacts: $dir" + fi + test_engine_info + test_network_dns + test_published_port +} + test_k8s() { command -v kubectl >/dev/null case "$CURRENT_ENGINE" in @@ -449,10 +606,49 @@ EOF [ "$ok" = "1" ] docker_e exec "$name" cat /proc/1/comm | grep -q systemd docker_e exec "$name" sh -c 'echo machine-exec-ok' | grep -q machine-exec-ok + docker_e exec "$name" sh -c 'getent hosts host.docker.internal >/dev/null || ping -c1 -W1 host.docker.internal >/dev/null' + docker_e exec "$name" sh -c 'getent hosts host.dory.internal >/dev/null || ping -c1 -W1 host.dory.internal >/dev/null' docker_e rm -f "$name" >/dev/null docker_e rmi -f "$tag" >/dev/null } +machine_recipe_command() { + local recipe="$1" + if [ -n "${READINESS_MACHINE_RECIPE_COMMAND:-}" ]; then + printf '%s' "$READINESS_MACHINE_RECIPE_COMMAND" + return + fi + case "$recipe" in + rust|rust-dev) printf '%s' 'test -x "$HOME/.cargo/bin/cargo" && "$HOME/.cargo/bin/cargo" --version' ;; + node) printf '%s' 'node --version && corepack --version' ;; + go) printf '%s' '. /etc/profile.d/go.sh 2>/dev/null || true; go version' ;; + python-ml) printf '%s' 'python3 --version && python3 -m pip --version' ;; + docker-host) printf '%s' 'docker --version' ;; + k8s-lab) printf '%s' 'kubectl version --client=true' ;; + *) printf '%s' 'cat /proc/1/comm | grep -Eq "systemd|tail"' ;; + esac +} + +test_machine_recipe() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + local recipe="${READINESS_MACHINE_RECIPE:-rust}" + local name="recipe-${RUN_SLUG}" + local cid="dory-machine-$name" + local command + command="$(machine_recipe_command "$recipe")" + docker_e rm -f "$cid" >/dev/null 2>&1 || true + trap 'docker_e rm -f "$cid" >/dev/null 2>&1 || true' RETURN + "$ROOT/scripts/dory" machine create "$name" --recipe "$recipe" >/dev/null + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + docker_e inspect "$cid" --format '{{.State.Running}}' 2>/dev/null | grep -q true && break + sleep 2 + done + docker_e inspect "$cid" --format '{{index .Config.Labels "dory.recipe"}}' | grep -qx "$recipe" + docker_e exec "$cid" sh -lc "$command" + docker_e rm -f "$cid" >/dev/null + trap - RETURN +} + test_bridge() { local mname="dory-brdg-$RUN_SLUG" local cname="dory-machine-$mname" @@ -484,6 +680,142 @@ test_bridge() { return 0 } +test_dax() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + local hv="${DORY_HV_BIN:-$ROOT/Packages/ContainerizationEngine/.build/debug/dory-hv}" + [ -x "$hv" ] || { echo "dory-hv not found or executable: $hv"; return 1; } + "$hv" daxprobe | grep -q "dax coherence passed" +} + +test_rosetta() { + local vm="${DORY_VM_HELPER:-$ROOT/Packages/ContainerizationEngine/.build/out/Products/Debug/dory-vmboot}" + [ -x "$vm" ] || { echo "dory-vm helper not found or executable: $vm"; return 1; } + [ -e /Library/Apple/usr/libexec/oah/RosettaLinux ] || { echo "Rosetta for Linux not installed (softwareupdate --install-rosetta)"; return 1; } + "$vm" --image "${DORY_ROSETTA_IMAGE:-docker.io/library/alpine:latest}" --arch amd64 --rosetta -- 'uname -m' 2>/dev/null | grep -qx x86_64 +} + +test_guest_agent() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + local hv="${DORY_HV_BIN:-$ROOT/Packages/ContainerizationEngine/.build/debug/dory-hv}" + local kernel="${DORY_GUEST_KERNEL:-$ROOT/guest/out/Image}" + local initfs="${DORY_GUEST_INITFS:-}" + [ -x "$hv" ] || { echo "dory-hv not found or executable: $hv"; return 1; } + [ -f "$kernel" ] || { echo "guest kernel not found: $kernel"; return 1; } + [ -n "$initfs" ] && [ -f "$initfs" ] || { echo "set DORY_GUEST_INITFS to a built initfs.ext4"; return 1; } + "$hv" agent-ping --kernel "$kernel" --initfs "$initfs" --mem-mb "${DORY_GUEST_AGENT_MEM_MB:-512}" --cpus 2 --timeout-sec "${DORY_GUEST_AGENT_TIMEOUT:-30}" \ + | tee "$WORKDIR/${ENGINE_ID}-guest-agent.json" \ + | grep -q '"kernel":"6.12.30-dory"' +} + +agent_rpc_readiness() { + local sock="$1" method="$2" payload="$3" + [ -n "$sock" ] && [ -S "$sock" ] || { echo "agent socket not found: $sock"; return 2; } + python3 - "$sock" "$method" "$payload" <<'PY' +import json, socket, struct, sys + +def recv_exactly(s, count): + buf = b"" + while len(buf) < count: + chunk = s.recv(count - len(buf)) + if not chunk: + return buf + buf += chunk + return buf + +sock_path, method, payload = sys.argv[1], sys.argv[2], json.loads(sys.argv[3]) +request = json.dumps({"id": 1, "method": method, "params": payload}, separators=(",", ":")).encode() +with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as s: + s.connect(sock_path) + s.sendall(struct.pack(">I", len(request)) + request) + header = recv_exactly(s, 4) + if len(header) != 4: + raise SystemExit("agent closed before response") + length = struct.unpack(">I", header)[0] + data = recv_exactly(s, length) + if len(data) != length: + raise SystemExit("agent closed mid-response") +response = json.loads(data) +if response.get("error"): + print(response["error"].get("message", "agent request failed"), file=sys.stderr) + raise SystemExit(1) +print(json.dumps(response.get("result", {}), sort_keys=True)) +PY +} + +agent_exec_stdout() { + local sock="$1" payload="$2" + agent_rpc_readiness "$sock" "exec" "$payload" | python3 -c ' +import base64, json, sys +result = json.load(sys.stdin) +if int(result.get("exit_code", 0)) != 0: + if result.get("stderr_b64"): + sys.stderr.buffer.write(base64.b64decode(result["stderr_b64"])) + raise SystemExit(int(result.get("exit_code", 1))) +if result.get("stdout_b64"): + sys.stdout.buffer.write(base64.b64decode(result["stdout_b64"])) +' +} + +clock_sync_helper_pid() { + if [ -n "${DORY_CLOCK_SYNC_PID:-}" ]; then + echo "$DORY_CLOCK_SYNC_PID" + return + fi + [ -f "$HOME/.dory/engine.pid" ] && cat "$HOME/.dory/engine.pid" +} + +test_clock_sync() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + local sock="${DORY_CLOCK_SYNC_AGENT_SOCK:-${DORY_AGENT_SOCK:-}}" + local pid guest_raw guest_ns host_ns delta_ms + [ -n "$sock" ] && [ -S "$sock" ] || { echo "set DORY_CLOCK_SYNC_AGENT_SOCK or DORY_AGENT_SOCK to a live guest agent bridge socket"; return 1; } + pid="$(clock_sync_helper_pid)" + [ -n "$pid" ] || { echo "set DORY_CLOCK_SYNC_PID or start Dory so $HOME/.dory/engine.pid exists"; return 1; } + kill -0 "$pid" 2>/dev/null || { echo "helper pid is not live: $pid"; return 1; } + + agent_exec_stdout "$sock" '{"argv":["date","-s","@946684800"],"timeout_ms":5000}' >/dev/null + kill -USR1 "$pid" + sleep "${DORY_CLOCK_SYNC_SETTLE:-0.5}" + guest_raw="$(agent_exec_stdout "$sock" '{"argv":["date","+%s%N"],"timeout_ms":5000}' | tr -d '[:space:]')" + case "$guest_raw" in + *[!0-9]*|"") echo "guest date does not support numeric nanosecond output: $guest_raw"; return 1 ;; + esac + guest_ns="$guest_raw" + host_ns="$(python3 - <<'PY' +import time +print(time.time_ns()) +PY +)" + delta_ms="$(python3 - "$host_ns" "$guest_ns" <<'PY' +import sys +host = int(sys.argv[1]) +guest = int(sys.argv[2]) +print(abs(host - guest) // 1_000_000) +PY +)" + [ "$delta_ms" -le "$CLOCK_SYNC_TOLERANCE_MS" ] || { + echo "clock delta ${delta_ms}ms exceeds tolerance ${CLOCK_SYNC_TOLERANCE_MS}ms" + return 1 + } + echo "clock delta ${delta_ms}ms" +} + +test_usb() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + if ! "$ROOT/scripts/dory" usb ls 2>/dev/null | grep -q "$DORY_USB_TEST_BUSID"; then + echo "device $DORY_USB_TEST_BUSID not present in 'dory usb ls'" >&2 + return 1 + fi + "$ROOT/scripts/dory" usb attach "$DORY_USB_TEST_BUSID" "${DORY_USBIP_PORT:-0}" || return 1 + "$ROOT/scripts/dory" usb detach "$DORY_USB_TEST_BUSID" "${DORY_USBIP_PORT:-0}" || return 1 +} + +test_debug_shell() { + [ "$CURRENT_ENGINE" = "dory" ] || return 2 + DORY_DEBUG_AGENT_SOCK="$DORY_DEBUG_AGENT_SOCK" "$ROOT/scripts/dory" debug "$DORY_DEBUG_CONTAINER_ID" -- /bin/sh -c 'echo dory-debug-ok' \ + | grep -q 'dory-debug-ok' +} + measure_memory() { local engine="$1" local image="$ALPINE_IMAGE" @@ -529,6 +861,11 @@ run_engine() { run_case "$CURRENT_ENGINE" "container lifecycle + logs + exec + stats" test_lifecycle_logs_exec_stats run_case "$CURRENT_ENGINE" "docker cp + export" test_cp_archive run_case "$CURRENT_ENGINE" "bind mount host read/write" test_bind_mount + if [ "$RUN_FILE_WATCH" = "1" ]; then + run_case "$CURRENT_ENGINE" "host file edit propagates to inotify" test_file_watch + else + skip_case "$CURRENT_ENGINE" "host file edit propagates to inotify" "enable with --file-watch" + fi run_case "$CURRENT_ENGINE" "volume create/use/inspect/remove" test_volume_roundtrip run_case "$CURRENT_ENGINE" "network create + service DNS" test_network_dns run_case "$CURRENT_ENGINE" "localhost published port" test_published_port @@ -555,6 +892,18 @@ run_engine() { skip_case "$CURRENT_ENGINE" "automatic local domains" "enable with --domains after system integration is installed" fi + if [ "$RUN_DIRECT_IP" = "1" ]; then + run_case "$CURRENT_ENGINE" "direct container IP ping + HTTP" test_direct_ip + else + skip_case "$CURRENT_ENGINE" "direct container IP ping + HTTP" "enable with --direct-ip after scripts/enable-networking.sh --direct-ip" + fi + + if [ "$RUN_VPN" = "1" ]; then + run_case "$CURRENT_ENGINE" "VPN coexistence userspace networking" test_vpn_coexistence + else + skip_case "$CURRENT_ENGINE" "VPN coexistence userspace networking" "enable with --vpn; set DORY_REQUIRE_VPN=1 to require an active VPN" + fi + if [ "$RUN_K8S" = "1" ]; then run_case "$CURRENT_ENGINE" "Kubernetes context reachable" test_k8s else @@ -567,12 +916,68 @@ run_engine() { skip_case "$CURRENT_ENGINE" "Linux machine build + systemd boot + exec" "enable with --machines" fi + if [ "$RUN_MACHINE_RECIPE" = "1" ]; then + run_case "$CURRENT_ENGINE" "Linux machine recipe create + provisioned command" test_machine_recipe + else + skip_case "$CURRENT_ENGINE" "Linux machine recipe create + provisioned command" "enable with --machine-recipe" + fi + if [ "$RUN_BRIDGE" = "1" ]; then run_case "$CURRENT_ENGINE" "guest→host bridge (dory-open open + forward)" test_bridge else skip_case "$CURRENT_ENGINE" "guest→host bridge (dory-open open + forward)" "enable with --bridge" fi + if [ "$RUN_GUEST_AGENT" = "1" ]; then + run_case "$CURRENT_ENGINE" "dory-hv guest-agent vsock ping" test_guest_agent + else + skip_case "$CURRENT_ENGINE" "dory-hv guest-agent vsock ping" "enable with --guest-agent and DORY_GUEST_INITFS" + fi + + if [ "$RUN_DAX" = "1" ]; then + run_case "$CURRENT_ENGINE" "dory-hv virtio-fs DAX coherence" test_dax + else + skip_case "$CURRENT_ENGINE" "dory-hv virtio-fs DAX coherence" "enable with --dax (needs a signed dory-hv)" + fi + + if [ "$RUN_ROSETTA" = "1" ]; then + run_case "$CURRENT_ENGINE" "Rosetta x86-64 machine execution" test_rosetta + else + skip_case "$CURRENT_ENGINE" "Rosetta x86-64 machine execution" "enable with --rosetta (needs a signed dory-vm + Rosetta installed)" + fi + + if [ "$RUN_CLOCK_SYNC" = "1" ] && [ "$CURRENT_ENGINE" != "dory" ]; then + skip_case "$CURRENT_ENGINE" "host wake clock sync" "Dory-only" + elif [ "$RUN_CLOCK_SYNC" = "1" ] && [ -z "${DORY_CLOCK_SYNC_AGENT_SOCK:-${DORY_AGENT_SOCK:-}}" ]; then + skip_case "$CURRENT_ENGINE" "host wake clock sync" "set DORY_CLOCK_SYNC_AGENT_SOCK or DORY_AGENT_SOCK" + elif [ "$RUN_CLOCK_SYNC" = "1" ] && { [ -z "${DORY_CLOCK_SYNC_PID:-}" ] && [ ! -f "$HOME/.dory/engine.pid" ]; }; then + skip_case "$CURRENT_ENGINE" "host wake clock sync" "set DORY_CLOCK_SYNC_PID or start Dory" + elif [ "$RUN_CLOCK_SYNC" = "1" ]; then + run_case "$CURRENT_ENGINE" "host wake clock sync" test_clock_sync + else + skip_case "$CURRENT_ENGINE" "host wake clock sync" "enable with --clock-sync and a live agent socket" + fi + + if [ "$RUN_USB" = "1" ] && [ -z "${DORY_USB_TEST_BUSID:-}" ]; then + skip_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "set DORY_USB_TEST_BUSID" + elif [ "$RUN_USB" = "1" ] && { [ -z "${DORY_USB_AGENT_SOCK:-}" ] || [ -z "${DORY_USBIP_SOCKET_FD:-}" ]; }; then + skip_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "set DORY_USB_AGENT_SOCK and DORY_USBIP_SOCKET_FD" + elif [ "$RUN_USB" = "1" ] && ! grep -q 'usb)' "$ROOT/scripts/dory"; then + skip_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "USB CLI attach surface pending" + elif [ "$RUN_USB" = "1" ]; then + run_case "$CURRENT_ENGINE" "USB/IP hardware smoke" test_usb + else + skip_case "$CURRENT_ENGINE" "USB/IP hardware smoke" "enable with --usb and DORY_USB_TEST_BUSID" + fi + + if [ "$RUN_DEBUG_SHELL" = "1" ] && { [ -z "${DORY_DEBUG_AGENT_SOCK:-}" ] || [ -z "${DORY_DEBUG_CONTAINER_ID:-}" ]; }; then + skip_case "$CURRENT_ENGINE" "debug shell via guest agent" "set DORY_DEBUG_AGENT_SOCK and DORY_DEBUG_CONTAINER_ID" + elif [ "$RUN_DEBUG_SHELL" = "1" ]; then + run_case "$CURRENT_ENGINE" "debug shell via guest agent" test_debug_shell + else + skip_case "$CURRENT_ENGINE" "debug shell via guest agent" "enable with --debug-shell" + fi + if [ "$RUN_MEMORY" = "1" ]; then run_case "$CURRENT_ENGINE" "memory delta for $MEMORY_COUNT idle containers" measure_memory "$CURRENT_ENGINE" else diff --git a/website/src/components/Install.tsx b/website/src/components/Install.tsx index 3db93be..c4e550e 100644 --- a/website/src/components/Install.tsx +++ b/website/src/components/Install.tsx @@ -48,9 +48,10 @@ export function Install() {

- Universal app for Intel and Apple silicon, macOS 15+. Dory's standalone engine needs Apple - silicon on macOS 26 (Tahoe); Intel Macs pair Dory with any Docker-compatible engine: Colima, - Docker Desktop, Rancher Desktop, Podman. + Apple silicon, macOS 15+ for Dory's own low-memory engine — nothing else to install. + On Intel, Dory runs as a native front-end for a Docker-compatible engine you install + (Colima, Docker Desktop, Rancher Desktop, Podman, OrbStack); a native Intel engine is + planned for a later update.