diff --git a/Package.swift b/Package.swift index c4e511a7..62251ded 100644 --- a/Package.swift +++ b/Package.swift @@ -42,6 +42,7 @@ let package = Package( .library(name: "ContainerPlugin", targets: ["ContainerPlugin"]), .library(name: "ContainerVersion", targets: ["ContainerVersion"]), .library(name: "ContainerXPC", targets: ["ContainerXPC"]), + .library(name: "ContainerOS", targets: ["ContainerOS"]), .library(name: "SocketForwarder", targets: ["SocketForwarder"]), .library(name: "TerminalProgress", targets: ["TerminalProgress"]), ], @@ -142,6 +143,7 @@ let package = Package( "ContainerResource", "ContainerVersion", "ContainerXPC", + "ContainerOS", "DNSServer", ], path: "Sources/Helpers/APIServer" @@ -400,6 +402,14 @@ let package = Package( "CAuditToken", ] ), + .target( + name: "ContainerOS", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + ], + path: "Sources/ContainerOS" + ), .target( name: "TerminalProgress", dependencies: [ @@ -418,6 +428,7 @@ let package = Package( .product(name: "DNSClient", package: "DNSClient"), .product(name: "DNS", package: "DNS"), .product(name: "Logging", package: "swift-log"), + .product(name: "ContainerizationOS", package: "containerization"), ] ), .testTarget( @@ -427,6 +438,12 @@ let package = Package( "DNSServer", ] ), + .testTarget( + name: "ContainerOSTests", + dependencies: [ + "ContainerOS" + ] + ), .target( name: "SocketForwarder", dependencies: [ diff --git a/Sources/ContainerOS/DirectoryWatcher.swift b/Sources/ContainerOS/DirectoryWatcher.swift new file mode 100644 index 00000000..83890a24 --- /dev/null +++ b/Sources/ContainerOS/DirectoryWatcher.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging +import Synchronization + +/// Watches a directory for changes and invokes a handler when the contents change. +/// +/// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory. +/// If the target directory does not exist yet, it polls until the directory is created. +/// the target is created, then transitions to watching the target directly. +/// +/// Example usage: +/// ```swift +/// let watcher = DirectoryWatcher(directoryURL: myURL, log: logger) +/// try watcher.startWatching { urls in +/// print("Directory contents changed: \(urls)") +/// } +/// ``` +public actor DirectoryWatcher { + public static let watchPeriod = Duration.seconds(1) + + /// The URL of the directory being watched. + public let directoryURL: URL + + private var task: Task? + private let monitorQueue: DispatchQueue + private let source: Mutex + + private let log: Logger? + + /// Creates a new `DirectoryWatcher` for the given directory URL. + /// + /// - Parameters: + /// - directoryURL: The URL of the directory to watch. + /// - log: An optional logger for diagnostic messages. + public init(directoryURL: URL, log: Logger?) { + self.directoryURL = directoryURL + self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)") + self.log = log + self.source = Mutex(nil) + } + + /// Starts watching the directory for changes. + /// + /// - Parameters: + /// - handler: handler to run on directory state change. + public func startWatching(handler: @Sendable @escaping ([URL]) throws -> Void) { + self.task = Task { + var exists: Bool + var isDir: ObjCBool = false + + while true { + do { + exists = FileManager.default.fileExists(atPath: self.directoryURL.path, isDirectory: &isDir) + if exists && isDir.boolValue && self.source.withLock({ $0 }) == nil { + try _startWatching(handler: handler) + } + } catch { + log?.error("failed to start watching", metadata: ["error": "\(error)"]) + } + + try await Task.sleep(for: Self.watchPeriod) + } + } + } + + private func _startWatching( + handler: @escaping ([URL]) throws -> Void + ) throws { + let descriptor = open(directoryURL.path, O_EVTONLY) + guard descriptor > 0 else { + throw ContainerizationError(.internalError, message: "cannot open \(directoryURL.path), descriptor=\(descriptor)") + } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) + try handler(files.map { directoryURL.appending(path: $0) }) + } catch { + throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryURL.path)") + } + + log?.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"]) + + let dispatchSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.delete, .write], + queue: monitorQueue + ) + + dispatchSource.setCancelHandler { + close(descriptor) + } + + dispatchSource.setEventHandler { [weak self] in + guard let self else { return } + + guard !dispatchSource.data.contains(.delete) else { + dispatchSource.cancel() + self.source.withLock { $0 = nil } + return + } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) + try handler(files.map { directoryURL.appending(path: $0) }) + } catch { + self.log?.error( + "failed to run watch handler", + metadata: ["error": "\(error)", "path": "\(directoryURL.path)"]) + } + } + + source.withLock { $0 = dispatchSource } + dispatchSource.resume() + } + + deinit { + self.task?.cancel() + source.withLock { $0?.cancel() } + } +} diff --git a/Sources/Helpers/APIServer/APIServer+Start.swift b/Sources/Helpers/APIServer/APIServer+Start.swift index 62fa0296..b4df3b09 100644 --- a/Sources/Helpers/APIServer/APIServer+Start.swift +++ b/Sources/Helpers/APIServer/APIServer+Start.swift @@ -91,10 +91,15 @@ extension APIServer { $0[$1.key.rawValue] = $1.value }), log: log) - await withThrowingTaskGroup(of: Void.self) { group in + await withTaskGroup(of: Result.self) { group in group.addTask { log.info("starting XPC server") - try await server.listen() + do { + try await server.listen() + return .success(()) + } catch { + return .failure(error) + } } // start up host table DNS @@ -111,35 +116,47 @@ extension APIServer { "port": "\(Self.dnsPort)", ] ) - try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + do { + try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + return .success(()) + } catch { + return .failure(error) + } } // start up realhost DNS - /* group.addTask { - let localhostResolver = LocalhostDNSHandler(log: log) do { - try localhostResolver.monitorResolvers() + let localhostResolver = LocalhostDNSHandler(log: log) + await localhostResolver.monitorResolvers() + + let nxDomainResolver = NxDomainResolver() + let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) + let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) + let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) + log.info( + "starting DNS resolver for localhost", + metadata: [ + "host": "\(Self.listenAddress)", + "port": "\(Self.localhostDNSPort)", + ] + ) + try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) + return .success(()) } catch { - log.error("could not initialize resolver monitor", metadata: ["error": "\(error)"]) - throw error + return .failure(error) + } + } + + for await result in group { + switch result { + case .success(): + continue + case .failure(let error): + log.error("API server task failed: \(error)") } - - let nxDomainResolver = NxDomainResolver() - let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver]) - let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver) - let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log) - log.info( - "starting DNS resolver for localhost", - metadata: [ - "host": "\(Self.listenAddress)", - "port": "\(Self.localhostDNSPort)", - ] - ) - try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort) } - */ } } catch { log.error( diff --git a/Sources/Helpers/APIServer/DirectoryWatcher.swift b/Sources/Helpers/APIServer/DirectoryWatcher.swift deleted file mode 100644 index c9d3bdfb..00000000 --- a/Sources/Helpers/APIServer/DirectoryWatcher.swift +++ /dev/null @@ -1,89 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the container project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import ContainerizationError -import Foundation -import Logging - -public class DirectoryWatcher { - public let directoryURL: URL - - private let monitorQueue: DispatchQueue - private var source: DispatchSourceFileSystemObject? - - private let log: Logger - - init(directoryURL: URL, log: Logger) { - self.directoryURL = directoryURL - self.monitorQueue = DispatchQueue(label: "monitor:\(directoryURL.path)") - self.log = log - } - - public func startWatching(handler: @escaping ([URL]) throws -> Void) throws { - guard source == nil else { - throw ContainerizationError(.invalidState, message: "already watching on \(directoryURL.path)") - } - - do { - let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) - try handler(files.map { directoryURL.appending(path: $0) }) - } catch { - throw ContainerizationError(.invalidState, message: "failed to start watching on \(directoryURL.path)") - } - - log.info("starting directory watcher", metadata: ["path": "\(directoryURL.path)"]) - - let descriptor = open(directoryURL.path, O_EVTONLY) - - let dispatchSource = DispatchSource.makeFileSystemObjectSource( - fileDescriptor: descriptor, - eventMask: .write, - queue: monitorQueue - ) - - // Close the file descriptor when the source is cancelled - dispatchSource.setCancelHandler { - close(descriptor) - } - - dispatchSource.setEventHandler { [weak self] in - guard let self else { return } - - do { - let files = try FileManager.default.contentsOfDirectory(atPath: directoryURL.path) - try handler(files.map { directoryURL.appending(path: $0) }) - } catch { - self.log.error( - "failed to run DirectoryWatcher handler", - metadata: [ - "error": "\(error)", - "path": "\(directoryURL.path)", - ]) - } - } - - source = dispatchSource - dispatchSource.resume() - } - - deinit { - guard let source else { - return - } - - source.cancel() - } -} diff --git a/Sources/Helpers/APIServer/LocalhostDNSHandler.swift b/Sources/Helpers/APIServer/LocalhostDNSHandler.swift index 46293444..cf87badb 100644 --- a/Sources/Helpers/APIServer/LocalhostDNSHandler.swift +++ b/Sources/Helpers/APIServer/LocalhostDNSHandler.swift @@ -15,50 +15,53 @@ //===----------------------------------------------------------------------===// import ContainerAPIClient +import ContainerOS import ContainerPersistence import ContainerizationError import DNS import DNSServer import Foundation import Logging +import Synchronization -class LocalhostDNSHandler: DNSHandler { +actor LocalhostDNSHandler: DNSHandler { private let ttl: UInt32 private let watcher: DirectoryWatcher - private var dns: [String: IPv4] + private let dns: Mutex<[String: IPv4]> public init(resolversURL: URL = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) { self.ttl = ttl self.watcher = DirectoryWatcher(directoryURL: resolversURL, log: log) - self.dns = [:] + self.dns = Mutex([:]) } - public func monitorResolvers() throws { - try self.watcher.startWatching { fileURLs in - var dns: [String: IPv4] = [:] + public func monitorResolvers() async { + await self.watcher.startWatching { fileURLs in + var dns: [String: String] = [:] let regex = try Regex(HostDNSResolver.localhostOptionsRegex) for file in fileURLs.filter({ $0.lastPathComponent.starts(with: HostDNSResolver.containerizationPrefix) }) { let content = try String(contentsOf: file, encoding: .utf8) if let match = content.firstMatch(of: regex), - let ipv4 = IPv4(String(match[1].substring ?? "")) + let ipv4 = (match[1].substring.map { String($0) }) { let name = String(file.lastPathComponent.dropFirst(HostDNSResolver.containerizationPrefix.count)) dns[name + "."] = ipv4 } } - self.dns = dns + self.dns.withLock { $0 = dns.compactMapValues { IPv4($0) } } } } - public func answer(query: Message) async throws -> Message? { + nonisolated public func answer(query: Message) async throws -> Message? { let question = query.questions[0] var record: ResourceRecord? switch question.type { case ResourceRecordType.host: + let dns = dns.withLock { $0 } if let ip = dns[question.name] { record = HostRecord(name: question.name, ttl: ttl, ip: ip) } diff --git a/Tests/ContainerOSTests/DirectoryWatcherTest.swift b/Tests/ContainerOSTests/DirectoryWatcherTest.swift new file mode 100644 index 00000000..447fd1a2 --- /dev/null +++ b/Tests/ContainerOSTests/DirectoryWatcherTest.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the container project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerOS +import ContainerizationError +import DNSServer +import Foundation +import Testing + +struct DirectoryWatcherTest { + let testUUID = UUID().uuidString + + private var testDir: URL! { + let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".clitests") + .appendingPathComponent(testUUID) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return tempDir + } + + private func withTempDir(_ body: (URL) async throws -> T) async throws -> T { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + return try await body(tempDir) + } + + private actor CreatedURLs { + nonisolated(unsafe) public var urls: [URL] + + public init() { + self.urls = [] + } + } + + @Test func testWatchingExistingDirectory() async throws { + try await withTempDir { tempDir in + + let watcher = DirectoryWatcher(directoryURL: tempDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + await watcher.startWatching { [createdURLs] urls in + for url in urls where url.lastPathComponent == name { + createdURLs.urls.append(url) + } + } + + try await Task.sleep(for: .milliseconds(100)) + let newFile = tempDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(100)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file") + #expect(createdURLs.urls.first!.lastPathComponent == name) + } + } + + @Test func testWatchingNonExistingDirectory() async throws { + try await withTempDir { tempDir in + let uuid = UUID().uuidString + let childDir = tempDir.appendingPathComponent(uuid) + + let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + await watcher.startWatching { [createdURLs] urls in + for url in urls where url.lastPathComponent == name { + createdURLs.urls.append(url) + } + } + + try await Task.sleep(for: .milliseconds(100)) + try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) + + try await Task.sleep(for: DirectoryWatcher.watchPeriod) + let newFile = childDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(100)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory") + #expect(createdURLs.urls.first!.lastPathComponent == name) + } + } + + @Test func testWatchingNonExistingParent() async throws { + try await withTempDir { tempDir in + let parent = UUID().uuidString + let child = UUID().uuidString + let childDir = tempDir.appendingPathComponent(parent).appendingPathComponent(child) + + let watcher = DirectoryWatcher(directoryURL: childDir, log: nil) + let createdURLs = CreatedURLs() + let name = "newFile" + + await watcher.startWatching { urls in + for url in urls where url.lastPathComponent == name { + createdURLs.urls.append(url) + } + } + + try await Task.sleep(for: .microseconds(100)) + try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) + + try await Task.sleep(for: DirectoryWatcher.watchPeriod) + + let newFile = childDir.appendingPathComponent(name) + FileManager.default.createFile(atPath: newFile.path, contents: nil) + try await Task.sleep(for: .milliseconds(100)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect parent directory") + #expect(createdURLs.urls.first!.lastPathComponent == name) + } + } + + @Test func testWatchingRecreatedDirectory() async throws { + try await withTempDir { tempDir in + let dir = tempDir.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let watcher = DirectoryWatcher(directoryURL: dir, log: nil) + let createdURLs = CreatedURLs() + let beforeDelete = "beforeDelete" + let afterDelete = "afterDelete" + + await watcher.startWatching { [createdURLs] urls in + for url in urls + where url.lastPathComponent == beforeDelete || url.lastPathComponent == afterDelete { + createdURLs.urls.append(url) + } + } + + try await Task.sleep(for: .milliseconds(100)) + let file1 = dir.appendingPathComponent(beforeDelete) + FileManager.default.createFile(atPath: file1.path, contents: nil) + try await Task.sleep(for: .milliseconds(100)) + + try FileManager.default.removeItem(at: dir) + try await Task.sleep(for: .milliseconds(100)) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try await Task.sleep(for: .milliseconds(1000)) + + let file2 = dir.appendingPathComponent(afterDelete) + FileManager.default.createFile(atPath: file2.path, contents: nil) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(!createdURLs.urls.isEmpty, "directory watcher failed to detect new file") + #expect(Set(createdURLs.urls.map { $0.lastPathComponent }) == Set([beforeDelete, afterDelete])) + } + + } +}